Merge upstream/master into dev (76 commits)

Resolved 5 conflicts:
- .github/workflows/docker.yml, release.yml: kept fork stubs (CI handled by build-prod/build-dev)
- server/src/routes/secrets.ts: kept fork's /usages route alongside upstream's /usage, /access-events
- server/src/services/secrets.ts: kept fork's usages() function and in-use deletion guard,
  layered before upstream's soft-delete + provider cleanup in remove()
- ui/src/api/secrets.ts: kept fork's usages() method alongside upstream's vault methods

Typechecks pass on @paperclipai/shared, @paperclipai/server, @paperclipai/ui.
This commit is contained in:
2026-05-11 18:01:34 -04:00
625 changed files with 145314 additions and 4442 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
@@ -2276,7 +2276,7 @@ function PromptsTab({
</div>
</div>
)}
<PackageFileTree
<FileTree
nodes={fileTree}
selectedFile={selectedOrEntryFile}
expandedDirs={expandedDirs}
+10 -1
View File
@@ -432,6 +432,15 @@ export function CompanyEnvironments() {
remote-managed adapters, and sandbox environments appear only when a run-capable sandbox provider plugin is
installed.
</div>
{sandboxCreationEnabled ? (
<div className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Installed sandbox providers:{" "}
<span className="font-medium text-foreground">
{discoveredPluginSandboxProviders.map((provider) => provider.displayName).join(", ")}
</span>
. These are not adapter types. They back the Sandbox driver for adapters that support sandbox execution.
</div>
) : null}
<div className="overflow-x-auto">
<table className="w-full min-w-[34rem] text-left text-xs">
@@ -442,7 +451,7 @@ export function CompanyEnvironments() {
<th className="px-3 py-2 font-medium">Local</th>
<th className="px-3 py-2 font-medium">SSH</th>
{sandboxSupportVisible ? (
<th className="px-3 py-2 font-medium">Sandbox</th>
<th className="px-3 py-2 font-medium">Sandbox via plugin</th>
) : null}
</tr>
</thead>
+4 -3
View File
@@ -50,8 +50,8 @@ import {
collectAllPaths,
parseFrontmatter,
FRONTMATTER_FIELD_LABELS,
PackageFileTree,
} from "../components/PackageFileTree";
FileTree,
} from "../components/FileTree";
/**
* Extract the set of agent/project/task slugs that are "checked" based on
@@ -1028,7 +1028,7 @@ export function CompanyExport() {
</div>
</div>
<div className="flex-1 overflow-y-auto">
<PackageFileTree
<FileTree
nodes={displayTree}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
@@ -1036,6 +1036,7 @@ export function CompanyExport() {
onToggleDir={handleToggleDir}
onSelectFile={selectFile}
onToggleCheck={handleToggleCheck}
wrapLabels={false}
/>
{totalTaskChildren > visibleTaskChildren && !treeSearch && (
<div className="px-4 py-2">
+4 -3
View File
@@ -43,8 +43,8 @@ import {
collectAllPaths,
parseFrontmatter,
FRONTMATTER_FIELD_LABELS,
PackageFileTree,
} from "../components/PackageFileTree";
FileTree,
} from "../components/FileTree";
import { readZipArchive } from "../lib/zip";
import { getPortableFileDataUrl, getPortableFileText, isPortableImageFile } from "../lib/portable-files";
@@ -1344,7 +1344,7 @@ export function CompanyImport() {
<h2 className="text-base font-semibold">Package files</h2>
</div>
<div className="flex-1 overflow-y-auto">
<PackageFileTree
<FileTree
nodes={tree}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
@@ -1354,6 +1354,7 @@ export function CompanyImport() {
onToggleCheck={handleToggleCheck}
renderFileExtra={(node, checked) => renderImportFileExtra(node, checked, renameMap)}
fileRowClassName={importFileRowClassName}
wrapLabels={false}
/>
</div>
</aside>
+4
View File
@@ -220,6 +220,10 @@ describe("CompanyEnvironments", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("Installed sandbox providers:");
expect(container.textContent).toContain("Secure Sandbox");
expect(container.textContent).toContain("These are not adapter types.");
const editButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Edit");
expect(editButton).toBeTruthy();
+33 -17
View File
@@ -20,6 +20,8 @@ import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
import { MarkdownEditor } from "../components/MarkdownEditor";
import { PageSkeleton } from "../components/PageSkeleton";
import { CopyText } from "../components/CopyText";
import { Identity } from "../components/Identity";
import {
Dialog,
DialogContent,
@@ -49,6 +51,7 @@ import {
Paperclip,
Pencil,
Plus,
Copy,
RefreshCw,
Save,
Search,
@@ -172,6 +175,12 @@ function shortRef(ref: string | null | undefined) {
return ref.slice(0, 7);
}
function middleTruncate(value: string, maxLength = 72) {
if (value.length <= maxLength) return value;
const edgeLength = Math.floor((maxLength - 3) / 2);
return `${value.slice(0, edgeLength)}...${value.slice(value.length - edgeLength)}`;
}
function formatProjectScanSummary(result: CompanySkillProjectScanResult) {
const parts = [
`${result.discovered} found`,
@@ -628,8 +637,6 @@ function SkillPane({
onSave: () => void;
savePending: boolean;
}) {
const { pushToast } = useToastActions();
if (!detail) {
if (loading) {
return <PageSkeleton variant="detail" />;
@@ -648,6 +655,7 @@ function SkillPane({
const body = file?.markdown ? stripFrontmatter(file.content) : file?.content ?? "";
const currentPin = shortRef(detail.sourceRef);
const latestPin = shortRef(updateStatus?.latestRef);
const displaySourcePath = detail.sourcePath ? middleTruncate(detail.sourcePath) : null;
const removeBlocked = usedBy.length > 0;
const removeDisabledReason = removeBlocked
? "Detach this skill from all agents before removing it."
@@ -693,20 +701,28 @@ function SkillPane({
<div className="mt-4 space-y-3 border-t border-border pt-4 text-sm">
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">Source</span>
<span className="flex items-center gap-2">
<span className="flex min-w-0 items-center gap-2">
<SourceIcon className="h-3.5 w-3.5 text-muted-foreground" />
{detail.sourcePath ? (
<button
className="truncate hover:text-foreground text-muted-foreground transition-colors cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(detail.sourcePath!);
pushToast({ title: "Copied path to workspace" });
}}
>
{source.label}
</button>
{detail.sourcePath && displaySourcePath ? (
<>
<span
className="block min-w-0 max-w-[min(34rem,55vw)] truncate font-mono text-xs text-muted-foreground"
title={detail.sourcePath}
>
{displaySourcePath}
</span>
<CopyText
text={detail.sourcePath}
copiedLabel="Copied path"
ariaLabel="Copy source path"
title="Copy source path"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<Copy className="h-3.5 w-3.5" />
</CopyText>
</>
) : (
<span className="truncate">{source.label}</span>
)}
@@ -767,14 +783,14 @@ function SkillPane({
{usedBy.length === 0 ? (
<span className="text-muted-foreground">No agents attached</span>
) : (
<div className="flex flex-wrap gap-x-3 gap-y-1">
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{usedBy.map((agent) => (
<Link
key={agent.id}
to={`/agents/${agent.urlKey}/skills`}
className="text-foreground no-underline hover:underline"
className="group rounded-md border border-transparent p-2 no-underline hover:border-border hover:bg-accent/40"
>
{agent.name}
<Identity name={agent.name} size="sm" />
</Link>
))}
</div>
+61 -77
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ExecutionWorkspace, Issue, Project, ProjectWorkspace, RoutineListItem } from "@paperclipai/shared";
import { ArrowLeft, Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import { Copy, ExternalLink, Loader2, Play, Repeat } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardAction } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -25,6 +25,7 @@ import {
} from "../components/RoutineRunVariablesDialog";
import {
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeQuickControls,
WorkspaceRuntimeControls,
type WorkspaceRuntimeControlRequest,
} from "../components/WorkspaceRuntimeControls";
@@ -53,13 +54,14 @@ type WorkspaceFormState = {
workspaceRuntime: string;
};
type ExecutionWorkspaceTab = "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspaceTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
const segments = pathname.split("/").filter(Boolean);
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
const tab = segments[executionWorkspacesIndex + 2];
if (tab === "services") return "services";
if (tab === "issues") return "issues";
if (tab === "routines") return "routines";
if (tab === "runtime-logs") return "runtime_logs";
@@ -72,6 +74,16 @@ function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceT
return `/execution-workspaces/${workspaceId}/${segment}`;
}
function LegacyWorkspaceTabRedirect({ workspaceId }: { workspaceId: string }) {
useEffect(() => {
try {
localStorage.removeItem(`paperclip:execution-workspace-tab:${workspaceId}`);
} catch {}
}, [workspaceId]);
return <Navigate to={executionWorkspaceTabPath(workspaceId, "issues")} replace />;
}
function isSafeExternalUrl(value: string | null | undefined) {
if (!value) return false;
try {
@@ -259,14 +271,14 @@ function WorkspaceLink({
function ExecutionWorkspaceIssuesList({
companyId,
workspaceId,
workspace,
issues,
isLoading,
error,
project,
}: {
companyId: string;
workspaceId: string;
workspace: ExecutionWorkspace;
issues: Issue[];
isLoading: boolean;
error: Error | null;
@@ -292,7 +304,7 @@ function ExecutionWorkspaceIssuesList({
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) => issuesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspaceId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByExecutionWorkspace(companyId, workspace.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
if (project?.id) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, project.id) });
@@ -304,6 +316,15 @@ function ExecutionWorkspaceIssuesList({
() => (project ? [{ id: project.id, name: project.name, workspaces: project.workspaces ?? [] }] : undefined),
[project],
);
const createIssueDefaults = useMemo(
() => ({
projectId: workspace.projectId,
...(workspace.projectWorkspaceId ? { projectWorkspaceId: workspace.projectWorkspaceId } : {}),
executionWorkspaceId: workspace.id,
executionWorkspaceMode: "reuse_existing",
}),
[workspace.id, workspace.projectId, workspace.projectWorkspaceId],
);
return (
<IssuesList
@@ -315,6 +336,7 @@ function ExecutionWorkspaceIssuesList({
liveIssueIds={liveIssueIds}
projectId={project?.id}
viewStateKey="paperclip:execution-workspace-issues-view"
baseCreateIssueDefaults={createIssueDefaults}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
);
@@ -663,25 +685,10 @@ export function ExecutionWorkspaceDetail() {
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
if (workspaceId && activeTab === null) {
let cachedTab: ExecutionWorkspaceTab = "configuration";
try {
const storedTab = localStorage.getItem(`paperclip:execution-workspace-tab:${workspaceId}`);
if (
storedTab === "issues" ||
storedTab === "routines" ||
storedTab === "configuration" ||
storedTab === "runtime_logs"
) {
cachedTab = storedTab;
}
} catch {}
return <Navigate to={executionWorkspaceTabPath(workspaceId, cachedTab)} replace />;
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
}
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
try {
localStorage.setItem(`paperclip:execution-workspace-tab:${workspace.id}`, tab);
} catch {}
navigate(executionWorkspaceTabPath(workspace.id, tab));
};
@@ -707,43 +714,39 @@ export function ExecutionWorkspaceDetail() {
return (
<>
<div className="space-y-4 overflow-hidden sm:space-y-6">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link to={project ? `/projects/${projectRef}/workspaces` : "/projects"}>
<ArrowLeft className="mr-1 h-4 w-4" />
Back to all workspaces
</Link>
</Button>
<StatusPill>{workspace.mode}</StatusPill>
<StatusPill>{workspace.providerType}</StatusPill>
<StatusPill className={workspace.status === "active" ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : undefined}>
{workspace.status}
</StatusPill>
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Execution workspace
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete runtime workspace that Paperclip reuses for this issue flow.
<span className="hidden sm:inline"> These settings stay attached to the execution workspace so future runs can keep local paths, repo refs, provisioning, teardown, and runtime-service behavior in sync with the actual workspace being reused.</span>
</p>
<WorkspaceRuntimeQuickControls
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
pendingRequest={pendingRuntimeAction}
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
</div>
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
<Card className="rounded-none">
<CardHeader>
<CardTitle>Services and jobs</CardTitle>
<CardDescription>
Source: {runtimeConfigSource === "execution_workspace"
? "execution workspace override"
: runtimeConfigSource === "project_workspace"
? "project workspace default"
: "none"}
</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "issues", label: "Issues" },
{ value: "services", label: "Services" },
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "routines", label: "Routines" },
]}
align="start"
value={activeTab ?? "issues"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
/>
</Tabs>
{activeTab === "services" ? (
<WorkspaceRuntimeControls
sections={runtimeControlSections}
isPending={controlRuntimeServices.isPending}
@@ -761,26 +764,7 @@ export function ExecutionWorkspaceDetail() {
}
onAction={(request) => controlRuntimeServices.mutate(request)}
/>
{runtimeActionErrorMessage ? <p className="mt-4 text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="mt-4 text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
</CardContent>
</Card>
<Tabs value={activeTab ?? "configuration"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "issues", label: "Issues" },
{ value: "routines", label: "Routines" },
]}
align="start"
value={activeTab ?? "configuration"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
/>
</Tabs>
{activeTab === "configuration" ? (
) : activeTab === "configuration" ? (
<div className="space-y-4 sm:space-y-6">
<Card className="rounded-none">
<CardHeader>
@@ -792,7 +776,7 @@ export function ExecutionWorkspaceDetail() {
<Button
variant="destructive"
size="sm"
className="w-full rounded-none sm:w-auto"
className="w-full sm:w-auto"
onClick={() => setCloseDialogOpen(true)}
disabled={workspace.status === "archived"}
>
@@ -1138,7 +1122,7 @@ export function ExecutionWorkspaceDetail() {
) : activeTab === "issues" ? (
<ExecutionWorkspaceIssuesList
companyId={workspace.companyId}
workspaceId={workspace.id}
workspace={workspace}
issues={linkedIssues}
isLoading={linkedIssuesQuery.isLoading}
error={linkedIssuesQuery.error as Error | null}
+1
View File
@@ -66,6 +66,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
workMode: overrides.workMode ?? "standard",
};
}
+54 -4
View File
@@ -18,6 +18,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useGeneralSettings } from "../context/GeneralSettingsContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { useDialogActions } from "../context/DialogContext";
import {
applyIssueFilters,
countActiveIssueFilters,
@@ -85,6 +86,7 @@ import {
Check,
ChevronRight,
Layers,
Plus,
XCircle,
X,
RotateCcw,
@@ -102,6 +104,7 @@ import {
ACTIONABLE_APPROVAL_STATUSES,
DEFAULT_INBOX_ISSUE_COLUMNS,
buildGroupedInboxSections,
buildInboxIssueGroupCreateDefaults,
buildInboxKeyboardNavEntries,
getAvailableInboxIssueColumns,
getInboxWorkItemKey,
@@ -652,6 +655,7 @@ function JoinRequestInboxRow({
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { openNewIssue } = useDialogActions();
const { isMobile } = useSidebar();
const navigate = useNavigate();
const location = useLocation();
@@ -946,10 +950,10 @@ export function Inbox() {
return map;
}, [projects]);
const projectWorkspaceById = useMemo(() => {
const map = new Map<string, { name: string }>();
const map = new Map<string, { name: string; projectId: string }>();
for (const project of projects ?? []) {
for (const workspace of project.workspaces ?? []) {
map.set(workspace.id, { name: workspace.name });
map.set(workspace.id, { name: workspace.name, projectId: project.id });
}
}
return map;
@@ -970,23 +974,40 @@ export function Inbox() {
name: string;
mode: "shared_workspace" | "isolated_workspace" | "operator_branch" | "adapter_managed" | "cloud_sandbox";
projectWorkspaceId: string | null;
projectId: string | null;
}>();
for (const workspace of executionWorkspaces) {
const projectWorkspace = workspace.projectWorkspaceId
? projectWorkspaceById.get(workspace.projectWorkspaceId) ?? null
: null;
map.set(workspace.id, {
name: workspace.name,
mode: workspace.mode,
projectWorkspaceId: workspace.projectWorkspaceId ?? null,
projectId: projectWorkspace?.projectId ?? null,
});
}
return map;
}, [executionWorkspaces]);
}, [executionWorkspaces, projectWorkspaceById]);
const inboxWorkspaceGrouping = useMemo<InboxWorkspaceGroupingOptions>(
() => ({
agentById,
executionWorkspaceById,
projectWorkspaceById,
defaultProjectWorkspaceIdByProjectId,
projectById,
userLabelById: companyUserLabelMap,
currentUserId,
}),
[defaultProjectWorkspaceIdByProjectId, executionWorkspaceById, projectWorkspaceById],
[
agentById,
companyUserLabelMap,
currentUserId,
defaultProjectWorkspaceIdByProjectId,
executionWorkspaceById,
projectById,
projectWorkspaceById,
],
);
const visibleIssueColumnSet = useMemo(() => new Set(visibleIssueColumns), [visibleIssueColumns]);
const availableIssueColumns = useMemo(
@@ -1231,6 +1252,17 @@ export function Inbox() {
issueSearchSupplementResults,
nestingEnabled,
]);
const openCreateIssueForGroup = useCallback((group: InboxGroupedSection) => {
const defaults = buildInboxIssueGroupCreateDefaults(
group.key,
groupBy,
group.displayItems,
inboxWorkspaceGrouping,
);
if (!defaults) return;
openNewIssue(defaults);
}, [groupBy, inboxWorkspaceGrouping, openNewIssue]);
const totalVisibleWorkItems = useMemo(
() => groupedSections.reduce((count, group) => count + group.displayItems.length, 0),
[groupedSections],
@@ -1990,6 +2022,8 @@ export function Inbox() {
{([
["none", "None"],
["type", "Type"],
["assignee", "Assignee"],
["project", "Project"],
...(isolatedWorkspacesEnabled ? ([["workspace", "Workspace"]] as const) : []),
] as const).map(([value, label]) => (
<button
@@ -2266,6 +2300,7 @@ export function Inbox() {
if (group.label) {
const groupNavIdx = groupFlatIndex.get(group.key) ?? -1;
const isGroupSelected = groupNavIdx >= 0 && selectedIndex === groupNavIdx;
const canCreateIssueInGroup = group.displayItems.some((item) => item.kind === "issue");
elements.push(
<div
key={`group-${group.key}`}
@@ -2284,6 +2319,21 @@ export function Inbox() {
collapsible
collapsed={isGroupCollapsed}
onToggle={() => toggleGroupCollapse(group.key)}
trailing={canCreateIssueInGroup ? (
<Button
variant="ghost"
size="icon-xs"
className="-mr-2 text-muted-foreground"
title={`New issue in ${group.label}`}
aria-label={`New issue in ${group.label}`}
onClick={(event) => {
event.stopPropagation();
openCreateIssueForGroup(group);
}}
>
<Plus className="h-3 w-3" />
</Button>
) : null}
/>
</div>,
);
+64
View File
@@ -192,6 +192,8 @@ vi.mock("../components/InlineEditor", () => ({
vi.mock("../components/IssueChatThread", () => ({
IssueChatThread: (props: {
onWorkModeChange?: (workMode: string) => void;
issueWorkMode?: string;
onStopRun?: (runId: string) => Promise<void>;
stopRunLabel?: string;
stoppingRunLabel?: string;
@@ -1099,6 +1101,7 @@ describe("IssueDetail", () => {
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
stopRunLabel: "Pause work",
stoppingRunLabel: "Pausing...",
issueWorkMode: "standard",
});
const chatPauseButton = Array.from(container.querySelectorAll("button"))
@@ -1129,6 +1132,67 @@ describe("IssueDetail", () => {
expect(pauseMenuButton).toBeTruthy();
});
it("passes planning work mode to the issue chat thread", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue({ workMode: "planning" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockIssueChatThreadRender.mock.calls.at(-1)?.[0]).toMatchObject({
issueWorkMode: "planning",
});
expect(container.textContent).toContain("Planning");
});
it("forwards composer work mode changes to the issues API", async () => {
const issue = createIssue();
mockIssuesApi.get.mockResolvedValue(issue);
mockIssuesApi.listAttachments.mockResolvedValue([
{
id: "attachment-1",
issueId: issue.id,
issueCommentId: null,
originalFilename: "planning-notes.txt",
contentPath: "/attachments/planning-notes.txt",
contentType: "text/plain",
byteSize: 4096,
uploadedByUserId: null,
uploadedAt: new Date("2026-04-21T00:02:00.000Z"),
},
]);
localStorage.setItem("paperclip:issue-comment-draft:issue-1", "Draft follow-up message");
mockIssuesApi.update.mockResolvedValue(createIssue({ workMode: "planning" }));
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDetail />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const lastChatThreadProps = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
expect(lastChatThreadProps?.issueWorkMode).toBe("standard");
expect(typeof lastChatThreadProps?.onWorkModeChange).toBe("function");
await act(async () => {
lastChatThreadProps?.onWorkModeChange?.("planning");
});
await flushReact();
expect(mockIssuesApi.update).toHaveBeenCalledWith(issue.identifier, { workMode: "planning" });
expect(localStorage.getItem("paperclip:issue-comment-draft:issue-1")).toBe("Draft follow-up message");
expect(container.textContent).toContain("planning-notes.txt");
localStorage.removeItem("paperclip:issue-comment-draft:issue-1");
});
it("renders Paused by board distinctly and defaults leaf resume to wake the assignee", async () => {
const activeHold = createPauseHold();
const releasedHold = createPauseHold({
+228 -58
View File
@@ -60,7 +60,7 @@ import {
} from "../lib/optimistic-issue-comments";
import { clearIssueExecutionRun, removeLiveRunById, upsertInterruptedRun } from "../lib/optimistic-issue-runs";
import { useProjectOrder } from "../hooks/useProjectOrder";
import { relativeTime, cn, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { relativeTime, cn, formatDurationMs, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { ApprovalCard } from "../components/ApprovalCard";
import { InlineEditor } from "../components/InlineEditor";
import { IssueChatThread, type IssueChatComposerHandle } from "../components/IssueChatThread";
@@ -70,6 +70,8 @@ import { IssuesList } from "../components/IssuesList";
import { AgentIcon } from "../components/AgentIconPicker";
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
import { IssueMonitorActivityCard } from "../components/IssueMonitorActivityCard";
import { IssueScheduledRetryCard } from "../components/IssueScheduledRetryCard";
import { IssueProperties } from "../components/IssueProperties";
import { IssueRunLedger } from "../components/IssueRunLedger";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
@@ -103,8 +105,15 @@ import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"
import { shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues";
import { filterIssueDescendants } from "../lib/issue-tree";
import { buildSubIssueDefaultsForViewer } from "../lib/subIssueDefaults";
import {
SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION,
SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION,
successfulRunHandoffActivityTone,
} from "../lib/successful-run-handoff";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
import {
Activity as ActivityIcon,
AlertTriangle,
Archive,
ArrowLeft,
Check,
@@ -112,6 +121,7 @@ import {
Copy,
Eye,
EyeOff,
Flag,
Hexagon,
ListTree,
MessageSquare,
@@ -138,6 +148,7 @@ import {
type Issue,
type IssueAttachment,
type IssueComment,
type IssueWorkMode,
type IssueThreadInteraction,
type RequestConfirmationInteraction,
type SuggestTasksInteraction,
@@ -179,7 +190,6 @@ const LEAF_WORK_CONTROL_MODE_HELP_TEXT: Partial<Record<IssueTreeControlMode, str
pause: "Pause active execution on this issue until an explicit resume.",
resume: "Release the active pause hold so this issue can continue.",
};
function issueTreeControlLabel(mode: IssueTreeControlMode, scope: "leaf" | "subtree") {
return scope === "leaf"
? LEAF_WORK_CONTROL_MODE_LABEL[mode] ?? TREE_CONTROL_MODE_LABEL[mode]
@@ -210,6 +220,15 @@ function resolveRunningIssueRun(
: (liveRuns ?? []).find((run) => run.status === "running") ?? null;
}
function dedupeLiveRunsById(liveRuns: readonly LiveRunForIssue[]) {
const seen = new Set<string>();
return liveRuns.filter((run) => {
if (seen.has(run.id)) return false;
seen.add(run.id);
return true;
});
}
function readIssueRunStateFromCache(queryClient: QueryClient, issueId: string) {
const liveRuns = queryClient.getQueryData<LiveRunForIssue[]>(
queryKeys.issues.liveRuns(issueId),
@@ -574,9 +593,11 @@ type IssueDetailChatTabProps = {
companyId: string;
projectId: string | null;
issueStatus: Issue["status"];
issueWorkMode: IssueWorkMode;
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@@ -584,6 +605,7 @@ type IssueDetailChatTabProps = {
commentsLoadingOlder: boolean;
onLoadOlderComments: () => void;
onRefreshLatestComments: () => Promise<unknown> | void;
onWorkModeChange?: (workMode: IssueWorkMode) => Promise<void> | void;
composerRef: Ref<IssueChatComposerHandle>;
feedbackVotes?: FeedbackVote[];
feedbackDataSharingPreference: "allowed" | "not_allowed" | "prompt";
@@ -624,16 +646,21 @@ type IssueDetailChatTabProps = {
answers: AskUserQuestionsAnswer[],
) => Promise<void>;
onCancelInteraction: (interaction: AskUserQuestionsInteraction) => Promise<void>;
assigneeUserId: string | null;
onResumeFromBacklog?: () => Promise<void> | void;
resumeFromBacklogPending?: boolean;
};
const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueId,
companyId,
projectId,
issueWorkMode,
issueStatus,
executionRunId,
blockedBy,
blockerAttention,
successfulRunHandoff,
comments,
locallyQueuedCommentRunIds,
interactions,
@@ -641,6 +668,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
commentsLoadingOlder,
onLoadOlderComments,
onRefreshLatestComments,
onWorkModeChange,
composerRef,
feedbackVotes,
feedbackDataSharingPreference,
@@ -671,6 +699,9 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onRejectInteraction,
onSubmitInteractionAnswers,
onCancelInteraction,
assigneeUserId,
onResumeFromBacklog,
resumeFromBacklogPending,
}: IssueDetailChatTabProps) {
const { data: activity } = useQuery({
queryKey: queryKeys.issues.activity(issueId),
@@ -835,6 +866,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
activeRun={resolvedActiveRun}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@@ -868,6 +900,8 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
onSubmitInteractionAnswers(interaction, answers)
}
onCancelInteraction={onCancelInteraction}
issueWorkMode={issueWorkMode}
onWorkModeChange={onWorkModeChange}
onCancelRun={runningIssueRun && onPauseWorkRun
? async () => {
await onPauseWorkRun(runningIssueRun.id);
@@ -875,12 +909,16 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
: undefined}
onImageClick={onImageClick}
onRefreshLatestComments={onRefreshLatestComments}
assigneeUserId={assigneeUserId}
onResumeFromBacklog={onResumeFromBacklog}
resumeFromBacklogPending={resumeFromBacklogPending}
/>
</div>
);
});
type IssueDetailActivityTabProps = {
issue: Issue;
issueId: string;
companyId: string;
issueStatus: Issue["status"];
@@ -891,10 +929,13 @@ type IssueDetailActivityTabProps = {
userProfileMap: Map<string, import("../lib/company-members").CompanyUserProfile>;
pendingApprovalAction: { approvalId: string; action: "approve" | "reject" } | null;
onApprovalAction: (approvalId: string, action: "approve" | "reject") => void;
onCheckMonitorNow: () => void;
checkingMonitorNow: boolean;
handoffFocusSignal?: number;
};
function IssueDetailActivityTab({
issue,
issueId,
companyId,
issueStatus,
@@ -905,6 +946,8 @@ function IssueDetailActivityTab({
userProfileMap,
pendingApprovalAction,
onApprovalAction,
onCheckMonitorNow,
checkingMonitorNow,
handoffFocusSignal = 0,
}: IssueDetailActivityTabProps) {
const { data: activity, isLoading: activityLoading } = useQuery({
@@ -950,8 +993,11 @@ function IssueDetailActivityTab({
let output = 0;
let cached = 0;
let cost = 0;
let runtimeMs = 0;
let runCount = 0;
let hasCost = false;
let hasTokens = false;
const nowMs = Date.now();
for (const run of linkedRuns ?? []) {
const usage = asRecord(run.usageJson);
@@ -971,6 +1017,15 @@ function IssueDetailActivityTab({
output += runOutput;
cached += runCached;
cost += runCost;
if (run.startedAt) {
const startMs = new Date(run.startedAt).getTime();
const endMs = run.finishedAt ? new Date(run.finishedAt).getTime() : nowMs;
if (Number.isFinite(startMs) && Number.isFinite(endMs) && endMs >= startMs) {
runtimeMs += endMs - startMs;
runCount += 1;
}
}
}
return {
@@ -981,6 +1036,9 @@ function IssueDetailActivityTab({
totalTokens: input + output,
hasCost,
hasTokens,
runtimeMs,
runCount,
hasRuntime: runtimeMs > 0,
};
}, [linkedRuns]);
const issueTreeCostTokens =
@@ -990,6 +1048,7 @@ function IssueDetailActivityTab({
&& (issueTreeCostSummary.costCents > 0
|| issueTreeCostTokens > 0
|| issueTreeCostSummary.cachedInputTokens > 0
|| issueTreeCostSummary.runtimeMs > 0
|| issueTreeCostSummary.issueCount > 1);
const shouldShowCostSummary =
(linkedRuns && linkedRuns.length > 0) || hasIssueTreeCost;
@@ -1022,7 +1081,13 @@ function IssueDetailActivityTab({
: ` (in ${formatTokens(issueCostSummary.input)}, out ${formatTokens(issueCostSummary.output)})`}
</span>
) : null}
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens ? (
{issueCostSummary.hasRuntime ? (
<span>
Runtime {formatDurationMs(issueCostSummary.runtimeMs)}
{` (${issueCostSummary.runCount} run${issueCostSummary.runCount === 1 ? "" : "s"})`}
</span>
) : null}
{!issueCostSummary.hasCost && !issueCostSummary.hasTokens && !issueCostSummary.hasRuntime ? (
<span>No direct cost data.</span>
) : null}
</div>
@@ -1042,6 +1107,12 @@ function IssueDetailActivityTab({
? ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)}, cached ${formatTokens(issueTreeCostSummary.cachedInputTokens)})`
: ` (in ${formatTokens(issueTreeCostSummary.inputTokens)}, out ${formatTokens(issueTreeCostSummary.outputTokens)})`}
</span>
{issueTreeCostSummary.runCount > 0 ? (
<span>
Runtime {formatDurationMs(issueTreeCostSummary.runtimeMs)}
{` (${issueTreeCostSummary.runCount} run${issueTreeCostSummary.runCount === 1 ? "" : "s"})`}
</span>
) : null}
<span>{issueTreeCostSummary.issueCount} issue{issueTreeCostSummary.issueCount === 1 ? "" : "s"}</span>
</div>
) : null}
@@ -1058,16 +1129,25 @@ function IssueDetailActivityTab({
agentMap={agentMap}
hasLiveRuns={hasLiveRuns}
activityEvents={activity ?? []}
renderActivityEvent={(evt) => (
<div className="space-y-1.5 rounded-lg border border-border/60 px-3 py-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
renderActivityEvent={(evt) => {
const tone = successfulRunHandoffActivityTone(evt.action);
const isHandoffWarning =
evt.action === SUCCESSFUL_RUN_HANDOFF_REQUIRED_ACTION
|| evt.action === SUCCESSFUL_RUN_HANDOFF_ESCALATED_ACTION;
return (
<div className={cn("space-y-1.5 rounded-lg border px-3 py-2 text-xs", tone.className)}>
<div className="flex items-center gap-1.5">
{isHandoffWarning ? (
<AlertTriangle className={cn("h-3.5 w-3.5 shrink-0", tone.iconClassName)} />
) : null}
<ActorIdentity evt={evt} agentMap={agentMap} userProfileMap={userProfileMap} />
<span>{formatIssueActivityAction(evt.action, evt.details, { agentMap, userProfileMap, currentUserId })}</span>
<span className="ml-auto shrink-0">{relativeTime(evt.createdAt)}</span>
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
<IssueReferenceActivitySummary event={evt} />
</div>
)}
);
}}
/>
</div>
{linkedApprovals && linkedApprovals.length > 0 && (
@@ -1091,6 +1171,12 @@ function IssueDetailActivityTab({
</div>
)}
<IssueContinuationHandoff document={continuationHandoff} focusSignal={handoffFocusSignal} />
<IssueScheduledRetryCard issueId={issue.id} scheduledRetry={issue.scheduledRetry ?? null} />
<IssueMonitorActivityCard
issue={issue}
onCheckNow={onCheckMonitorNow}
checkingNow={checkingMonitorNow}
/>
</>
);
}
@@ -1460,23 +1546,36 @@ export function IssueDetail() {
[comments, optimisticComments],
);
const breadcrumbTitle = issue?.title ?? issueId ?? "Issue";
const issueCacheRefs = useMemo(() => {
const refs = new Set<string>();
if (issueId) refs.add(issueId);
if (issue?.id) refs.add(issue.id);
if (issue?.identifier) refs.add(issue.identifier);
return [...refs];
}, [issue?.id, issue?.identifier, issueId]);
const invalidateIssueDetail = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!) });
}, [issueId, queryClient]);
for (const ref of issueCacheRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref) });
}
}, [issueCacheRefs, queryClient]);
const invalidateIssueThreadLazily = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(issueId!), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(issueId!), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(issueId!), refetchType: "inactive" });
}, [issueId, queryClient]);
for (const ref of issueCacheRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.detail(ref), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activity(ref), refetchType: "inactive" });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.interactions(ref), refetchType: "inactive" });
}
}, [issueCacheRefs, queryClient]);
const invalidateIssueRunState = useCallback(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
}, [issueId, queryClient]);
for (const ref of issueCacheRefs) {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.runs(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.liveRuns(ref) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.activeRun(ref) });
}
}, [issueCacheRefs, queryClient]);
const removeCommentFromCache = useCallback((commentId: string) => {
queryClient.setQueryData<InfiniteData<IssueComment[], string | null> | undefined>(
@@ -1754,6 +1853,26 @@ export function IssueDetail() {
updateChildIssue.mutate({ id, data });
}, [updateChildIssue]);
const checkIssueMonitorNow = useMutation({
mutationFn: () => issuesApi.checkMonitorNow(issueId!),
onSuccess: () => {
invalidateIssueDetail();
invalidateIssueRunState();
invalidateIssueCollections();
pushToast({
title: "Monitor check queued",
tone: "success",
});
},
onError: (err) => {
pushToast({
title: "Monitor check failed",
body: err instanceof Error ? err.message : "Unable to trigger the monitor right now",
tone: "error",
});
},
});
const approvalDecision = useMutation({
mutationFn: async ({ approvalId, action }: { approvalId: string; action: "approve" | "reject" }) => {
if (action === "approve") {
@@ -2119,18 +2238,26 @@ export function IssueDetail() {
const interruptQueuedComment = useMutation({
mutationFn: (runId: string) => heartbeatsApi.cancel(runId),
onMutate: async (runId) => {
await queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(issueId!) });
await queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(issueId!) });
await Promise.all(issueCacheRefs.flatMap((ref) => [
queryClient.cancelQueries({ queryKey: queryKeys.issues.runs(ref) }),
queryClient.cancelQueries({ queryKey: queryKeys.issues.liveRuns(ref) }),
queryClient.cancelQueries({ queryKey: queryKeys.issues.activeRun(ref) }),
queryClient.cancelQueries({ queryKey: queryKeys.issues.detail(ref) }),
]));
const previousRuns = queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(issueId!));
const previousLiveRuns = queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(issueId!));
const previousActiveRun = queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(issueId!));
const previousIssue = queryClient.getQueryData<Issue>(queryKeys.issues.detail(issueId!));
const previousRunState = issueCacheRefs.map((ref) => ({
ref,
runs: queryClient.getQueryData<RunForIssue[]>(queryKeys.issues.runs(ref)),
liveRuns: queryClient.getQueryData<LiveRunForIssue[]>(queryKeys.issues.liveRuns(ref)),
activeRun: queryClient.getQueryData<ActiveRunForIssue | null>(queryKeys.issues.activeRun(ref)),
issue: queryClient.getQueryData<Issue>(queryKeys.issues.detail(ref)),
}));
const previousLocalQueuedCommentRunIds = locallyQueuedCommentRunIds;
const liveRunList = previousLiveRuns ?? [];
const cachedActiveRun = previousActiveRun ?? null;
const cachedActiveRun =
previousRunState.find((state) => state.activeRun?.id === runId)?.activeRun ??
previousRunState.find((state) => state.activeRun)?.activeRun ??
null;
const liveRunList = dedupeLiveRunsById(previousRunState.flatMap((state) => state.liveRuns ?? []));
const runningIssueRun = resolveRunningIssueRun(cachedActiveRun, liveRunList);
const targetRun =
cachedActiveRun?.id === runId
@@ -2139,34 +2266,35 @@ export function IssueDetail() {
if (targetRun) {
const interruptedAt = new Date().toISOString();
queryClient.setQueryData<RunForIssue[] | undefined>(
queryKeys.issues.runs(issueId!),
(current) => upsertInterruptedRun(current, targetRun, interruptedAt),
);
for (const ref of issueCacheRefs) {
queryClient.setQueryData<RunForIssue[] | undefined>(
queryKeys.issues.runs(ref),
(current) => upsertInterruptedRun(current, targetRun, interruptedAt),
);
}
}
queryClient.setQueryData(
queryKeys.issues.liveRuns(issueId!),
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
);
queryClient.setQueryData(
queryKeys.issues.activeRun(issueId!),
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
);
queryClient.setQueryData(
queryKeys.issues.detail(issueId!),
(current: Issue | undefined) => clearIssueExecutionRun(current, runId),
);
for (const ref of issueCacheRefs) {
queryClient.setQueryData(
queryKeys.issues.liveRuns(ref),
(current: LiveRunForIssue[] | undefined) => removeLiveRunById(current, runId),
);
queryClient.setQueryData(
queryKeys.issues.activeRun(ref),
(current: ActiveRunForIssue | null | undefined) => (current?.id === runId ? null : current),
);
queryClient.setQueryData(
queryKeys.issues.detail(ref),
(current: Issue | undefined) => clearIssueExecutionRun(current, runId),
);
}
setLocallyQueuedCommentRunIds((current) => {
const next = new Map([...current].filter(([, targetRunId]) => targetRunId !== runId));
return next.size === current.size ? current : next;
});
return {
previousRuns,
previousLiveRuns,
previousActiveRun,
previousIssue,
previousRunState,
previousLocalQueuedCommentRunIds,
};
},
@@ -2180,10 +2308,12 @@ export function IssueDetail() {
});
},
onError: (err, _runId, context) => {
queryClient.setQueryData(queryKeys.issues.runs(issueId!), context?.previousRuns);
queryClient.setQueryData(queryKeys.issues.liveRuns(issueId!), context?.previousLiveRuns);
queryClient.setQueryData(queryKeys.issues.activeRun(issueId!), context?.previousActiveRun);
queryClient.setQueryData(queryKeys.issues.detail(issueId!), context?.previousIssue);
for (const state of context?.previousRunState ?? []) {
queryClient.setQueryData(queryKeys.issues.runs(state.ref), state.runs);
queryClient.setQueryData(queryKeys.issues.liveRuns(state.ref), state.liveRuns);
queryClient.setQueryData(queryKeys.issues.activeRun(state.ref), state.activeRun);
queryClient.setQueryData(queryKeys.issues.detail(state.ref), state.issue);
}
if (context?.previousLocalQueuedCommentRunIds) {
setLocallyQueuedCommentRunIds(context.previousLocalQueuedCommentRunIds);
}
@@ -2775,6 +2905,10 @@ export function IssueDetail() {
const handleCancelInteraction = useCallback(async (interaction: AskUserQuestionsInteraction) => {
await cancelInteraction.mutateAsync({ interaction });
}, [cancelInteraction]);
const canResumeFromBacklog = issue?.status === "backlog" && Boolean(issue.assigneeAgentId || issue.assigneeUserId);
const handleResumeFromBacklog = useCallback(async () => {
await updateIssue.mutateAsync({ status: "todo" });
}, [updateIssue.mutateAsync]);
const treePreviewAffectedIssues = useMemo(
() => (treeControlPreview?.issues ?? []).filter((candidate) => !candidate.skipped),
@@ -3112,6 +3246,26 @@ export function IssueDetail() {
</span>
) : null}
{issue.workMode === "planning" ? (
<span
className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
title="This issue is in planning mode."
>
Planning
</span>
) : null}
{hasAssignedBacklogBlocker(issue.blockedBy) ? (
<span
data-testid="issue-detail-parked-blocker"
className="inline-flex items-center gap-1 rounded-full border border-amber-500/60 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-700 dark:text-amber-300 shrink-0"
title="Blocked by parked work — at least one assigned blocker is in backlog and will not wake its assignee."
>
<Flag className="h-3 w-3" />
Blocked by parked work
</span>
) : null}
{issue.projectId ? (
<Link
to={`/projects/${issue.projectId}`}
@@ -3416,6 +3570,7 @@ export function IssueDetail() {
createIssueLabel="Sub-issue"
defaultSortField="workflow"
showProgressSummary
parentIssueIdForCostSummary={issue.id}
onUpdateIssue={handleChildIssueUpdate}
/>
</div>
@@ -3627,9 +3782,11 @@ export function IssueDetail() {
companyId={issue.companyId}
projectId={issue.projectId ?? null}
issueStatus={issue.status}
issueWorkMode={issue.workMode ?? "standard"}
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
@@ -3661,6 +3818,11 @@ export function IssueDetail() {
onPauseWorkRun={canManageTreeControl
? (runId) => pauseIssueWorkRun.mutateAsync({ runId, scope: treeControlScope }).then(() => undefined)
: undefined}
onWorkModeChange={(nextMode) => {
const currentMode: IssueWorkMode = issue.workMode ?? "standard";
if (currentMode === nextMode) return;
return updateIssue.mutateAsync({ workMode: nextMode }).then(() => undefined);
}}
onCancelQueued={handleCancelQueuedComment}
interruptingQueuedRunId={interruptQueuedComment.isPending ? interruptQueuedComment.variables ?? null : null}
pausingWorkRunId={pauseIssueWorkRun.isPending ? pauseIssueWorkRun.variables?.runId ?? null : null}
@@ -3669,6 +3831,11 @@ export function IssueDetail() {
onRejectInteraction={handleRejectInteraction}
onSubmitInteractionAnswers={handleSubmitInteractionAnswers}
onCancelInteraction={handleCancelInteraction}
assigneeUserId={issue.assigneeUserId ?? null}
onResumeFromBacklog={canResumeFromBacklog ? handleResumeFromBacklog : undefined}
resumeFromBacklogPending={
updateIssue.isPending && updateIssue.variables?.status === "todo"
}
/>
) : null}
</TabsContent>
@@ -3676,6 +3843,7 @@ export function IssueDetail() {
<TabsContent value="activity">
{detailTab === "activity" ? (
<IssueDetailActivityTab
issue={issue}
issueId={issue.id}
companyId={issue.companyId}
issueStatus={issue.status}
@@ -3689,6 +3857,8 @@ export function IssueDetail() {
onApprovalAction={(approvalId, action) => {
approvalDecision.mutate({ approvalId, action });
}}
onCheckMonitorNow={() => checkIssueMonitorNow.mutate()}
checkingMonitorNow={checkIssueMonitorNow.isPending}
/>
) : null}
</TabsContent>
+3 -38
View File
@@ -34,6 +34,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 { DEFAULT_OPENCODE_LOCAL_MODEL, isValidOpenCodeModelId } from "@paperclipai/adapter-opencode-local";
function createValuesForAdapterType(
adapterType: CreateConfigValues["adapterType"],
@@ -49,7 +50,7 @@ function createValuesForAdapterType(
} else if (adapterType === "cursor") {
nextValues.model = DEFAULT_CURSOR_LOCAL_MODEL;
} else if (adapterType === "opencode_local") {
nextValues.model = "";
nextValues.model = DEFAULT_OPENCODE_LOCAL_MODEL;
}
return nextValues;
}
@@ -86,19 +87,6 @@ export function NewAgent() {
enabled: !!selectedCompanyId,
});
const {
data: adapterModels,
error: adapterModelsError,
isLoading: adapterModelsLoading,
isFetching: adapterModelsFetching,
} = useQuery({
queryKey: selectedCompanyId
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
: ["agents", "none", "adapter-models", configValues.adapterType],
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
enabled: Boolean(selectedCompanyId),
});
const { data: companySkills } = useQuery({
queryKey: queryKeys.companySkills.list(selectedCompanyId ?? ""),
queryFn: () => companySkillsApi.list(selectedCompanyId!),
@@ -154,32 +142,10 @@ export function NewAgent() {
if (!selectedCompanyId || !name.trim()) return;
setFormError(null);
if (configValues.adapterType === "opencode_local") {
const selectedModel = configValues.model.trim();
if (!selectedModel) {
if (!isValidOpenCodeModelId(configValues.model)) {
setFormError("OpenCode requires an explicit model in provider/model format.");
return;
}
if (adapterModelsError) {
setFormError(
adapterModelsError instanceof Error
? adapterModelsError.message
: "Failed to load OpenCode models.",
);
return;
}
if (adapterModelsLoading || adapterModelsFetching) {
setFormError("OpenCode models are still loading. Please wait and try again.");
return;
}
const discovered = adapterModels ?? [];
if (!discovered.some((entry) => entry.id === selectedModel)) {
setFormError(
discovered.length === 0
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
: `Configured OpenCode model is unavailable: ${selectedModel}`,
);
return;
}
}
createAgent.mutate(
buildNewAgentHirePayload({
@@ -295,7 +261,6 @@ export function NewAgent() {
mode="create"
values={configValues}
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
adapterModels={adapterModels}
onTestActionChange={handleTestAgentActionChange}
onTestActionStateChange={handleTestAgentStateChange}
onTestFeedbackChange={handleTestAgentFeedbackChange}
+207
View File
@@ -0,0 +1,207 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PluginPage } from "./PluginPage";
const mockPluginsApi = vi.hoisted(() => ({
listUiContributions: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockParams = vi.hoisted(() => ({
companyPrefix: "PAP" as string | undefined,
pluginId: undefined as string | undefined,
pluginRoutePath: undefined as string | undefined,
"*": undefined as string | undefined,
}));
vi.mock("@/api/plugins", () => ({
pluginsApi: mockPluginsApi,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
Navigate: () => null,
useParams: () => mockParams,
}));
vi.mock("@/plugins/slots", async () => {
const actual = await vi.importActual<typeof import("@/plugins/slots")>("@/plugins/slots");
return {
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
PluginSlotMount: ({ slot }: { slot: { displayName: string } }) => (
<div data-testid="plugin-slot-mount">{slot.displayName}</div>
),
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
function pageContribution(overrides: Partial<{ slots: unknown[] }> = {}) {
return {
pluginId: "plugin-wiki",
pluginKey: "paperclipai.plugin-llm-wiki",
displayName: "LLM Wiki",
version: "0.1.0",
uiEntryFile: "ui.js",
slots: [
{
type: "page",
id: "wiki-page",
displayName: "Wiki",
exportName: "WikiPage",
routePath: "wiki",
},
],
launchers: [],
...overrides,
};
}
async function renderPage(container: HTMLDivElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PluginPage />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
return root;
}
describe("PluginPage", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockParams.companyPrefix = "PAP";
mockParams.pluginId = undefined;
mockParams.pluginRoutePath = undefined;
mockParams["*"] = undefined;
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("renders the breadcrumb and Back button on a legacy plugin route (no routeSidebar)", async () => {
mockParams.pluginRoutePath = "wiki";
mockPluginsApi.listUiContributions.mockResolvedValue([pageContribution()]);
const root = await renderPage(container);
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
{ label: "Plugins", href: "/instance/settings/plugins" },
{ label: "LLM Wiki" },
]);
expect(container.textContent).toContain("Back");
expect(container.querySelector('a[href="/PAP/dashboard"]')).not.toBeNull();
await act(async () => {
root.unmount();
});
});
it("uses a route title and hides the Back button when a routeSidebar matches the active route", async () => {
mockParams.pluginRoutePath = "wiki";
mockPluginsApi.listUiContributions.mockResolvedValue([
pageContribution({
slots: [
{
type: "page",
id: "wiki-page",
displayName: "Wiki",
exportName: "WikiPage",
routePath: "wiki",
},
{
type: "routeSidebar",
id: "wiki-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiRouteSidebar",
routePath: "wiki",
},
],
}),
]);
const root = await renderPage(container);
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([{ label: "Wiki" }]);
expect(container.textContent).not.toContain("Back");
expect(container.querySelector('a[href="/PAP/dashboard"]')).toBeNull();
// Page slot itself still renders.
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe("Wiki");
await act(async () => {
root.unmount();
});
});
it("uses the selected plugin page path as the route-sidebar title", async () => {
mockParams.pluginRoutePath = "wiki";
mockParams["*"] = "page/templates%3A%3Aindex.md";
mockPluginsApi.listUiContributions.mockResolvedValue([
pageContribution({
slots: [
{
type: "page",
id: "wiki-page",
displayName: "Wiki",
exportName: "WikiPage",
routePath: "wiki",
},
{
type: "routeSidebar",
id: "wiki-sidebar",
displayName: "Wiki Sidebar",
exportName: "WikiRouteSidebar",
routePath: "wiki",
},
],
}),
]);
const root = await renderPage(container);
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([{ label: "index" }]);
await act(async () => {
root.unmount();
});
});
});
+83 -16
View File
@@ -5,7 +5,11 @@ import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { PluginSlotMount } from "@/plugins/slots";
import {
PluginSlotMount,
resolveRouteSidebarSlot,
type ResolvedPluginSlot,
} from "@/plugins/slots";
import { Button } from "@/components/ui/button";
import { ArrowLeft } from "lucide-react";
import { NotFoundPage } from "./NotFound";
@@ -19,11 +23,14 @@ import { NotFoundPage } from "./NotFound";
* @see doc/plugins/PLUGIN_SPEC.md §24.4 — Company-Context Plugin Page
*/
export function PluginPage() {
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = useParams<{
const params = useParams<{
companyPrefix?: string;
pluginId?: string;
pluginRoutePath?: string;
"*": string | undefined;
}>();
const { companyPrefix: routeCompanyPrefix, pluginId, pluginRoutePath } = params;
const pluginRouteSplat = params["*"];
const { companies, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const routeCompany = useMemo(() => {
@@ -89,14 +96,33 @@ export function PluginPage() {
[resolvedCompanyId, companyPrefix],
);
// When the active route has a routeSidebar slot, the sidebar provides the
// back affordance, but the top bar still needs a route-specific title.
const routeSidebarActive = useMemo(() => {
if (!pluginRoutePath || !contributions) return false;
const flattened: ResolvedPluginSlot[] = contributions.flatMap((contribution) =>
contribution.slots.map((slot) => ({
...slot,
pluginId: contribution.pluginId,
pluginKey: contribution.pluginKey,
pluginDisplayName: contribution.displayName,
pluginVersion: contribution.version,
})),
);
return resolveRouteSidebarSlot(flattened, pluginRoutePath) !== null;
}, [contributions, pluginRoutePath]);
useEffect(() => {
if (pageSlot) {
setBreadcrumbs([
{ label: "Plugins", href: "/instance/settings/plugins" },
{ label: pageSlot.pluginDisplayName },
]);
if (!pageSlot) return;
if (routeSidebarActive) {
setBreadcrumbs([{ label: resolveRouteSidebarPageTitle(pageSlot, pluginRouteSplat) }]);
return;
}
}, [pageSlot, companyPrefix, setBreadcrumbs]);
setBreadcrumbs([
{ label: "Plugins", href: "/instance/settings/plugins" },
{ label: pageSlot.pluginDisplayName },
]);
}, [pageSlot, pluginRouteSplat, setBreadcrumbs, routeSidebarActive]);
if (!resolvedCompanyId) {
if (hasInvalidCompanyPrefix) {
@@ -137,14 +163,16 @@ export function PluginPage() {
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
</Button>
</div>
{!routeSidebarActive && (
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={companyPrefix ? `/${companyPrefix}/dashboard` : "/dashboard"}>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
</Button>
</div>
)}
<PluginSlotMount
slot={pageSlot}
context={context}
@@ -154,3 +182,42 @@ export function PluginPage() {
</div>
);
}
function resolveRouteSidebarPageTitle(pageSlot: ResolvedPluginSlot, routeSplat: string | undefined): string {
const title = titleFromRouteSplat(routeSplat);
return title ?? pageSlot.displayName ?? pageSlot.pluginDisplayName;
}
function titleFromRouteSplat(routeSplat: string | undefined): string | null {
const segments = (routeSplat ?? "")
.split("/")
.filter(Boolean)
.map(decodeRouteSegment);
if (segments.length === 0) return null;
if (segments[0] === "page" && segments.length > 1) {
return titleFromPath(segments.slice(1).join("/"), { preserveCase: true });
}
return titleFromPath(segments[0] ?? null);
}
function titleFromPath(path: string | null | undefined, options: { preserveCase?: boolean } = {}): string | null {
const trimmed = path?.trim();
if (!trimmed) return null;
const basename = trimmed.split("/").filter(Boolean).at(-1) ?? trimmed;
const withoutNamespace = basename.split("::").at(-1) ?? basename;
const withoutExtension = withoutNamespace.replace(/\.[^.]+$/, "");
const normalized = withoutExtension.replace(/[-_]+/g, " ").trim();
if (!normalized) return null;
if (options.preserveCase) return normalized;
return normalized.replace(/\b\w/g, (char) => char.toUpperCase());
}
function decodeRouteSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
+249 -37
View File
@@ -12,6 +12,8 @@ const mockPluginsApi = vi.hoisted(() => ({
dashboard: vi.fn(),
logs: vi.fn(),
getConfig: vi.fn(),
listLocalFolders: vi.fn(),
configureLocalFolder: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
@@ -58,6 +60,82 @@ async function flushReact() {
});
}
function basePlugin(overrides: Record<string, unknown> = {}) {
return {
id: "plugin-1",
pluginKey: "paperclip.e2b-sandbox-provider",
packageName: "@paperclipai/plugin-e2b",
version: "0.1.0",
status: "error",
categories: ["automation"],
manifestJson: {
displayName: "E2B Sandbox Provider",
version: "0.1.0",
description: "E2B environments for Paperclip.",
author: "Paperclip",
capabilities: ["environment.drivers.register"],
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B Cloud Sandbox",
},
],
},
lastError: null,
...overrides,
};
}
function wikiFolderDeclaration() {
return {
folderKey: "wiki-root",
displayName: "Wiki root",
description: "Company-scoped local folder that stores wiki files.",
access: "readWrite" as const,
requiredDirectories: ["raw", "wiki"],
requiredFiles: ["WIKI.md", "index.md"],
};
}
function folderStatus(overrides: Record<string, unknown> = {}) {
return {
folderKey: "wiki-root",
configured: false,
path: null,
realPath: null,
access: "readWrite",
readable: false,
writable: false,
requiredDirectories: ["raw", "wiki"],
requiredFiles: ["WIKI.md", "index.md"],
missingDirectories: ["raw", "wiki"],
missingFiles: ["WIKI.md", "index.md"],
healthy: false,
problems: [{ code: "not_configured", message: "No local folder path is configured." }],
checkedAt: "2026-05-02T16:00:00.000Z",
...overrides,
};
}
async function renderSettings(container: HTMLDivElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PluginSettings />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
return root;
}
describe("PluginSettings", () => {
let container: HTMLDivElement;
@@ -65,30 +143,16 @@ describe("PluginSettings", () => {
container = document.createElement("div");
document.body.appendChild(container);
mockPluginsApi.get.mockResolvedValue({
id: "plugin-1",
pluginKey: "paperclip.e2b-sandbox-provider",
packageName: "@paperclipai/plugin-e2b",
version: "0.1.0",
status: "error",
categories: ["automation"],
manifestJson: {
displayName: "E2B Sandbox Provider",
version: "0.1.0",
description: "E2B environments for Paperclip.",
author: "Paperclip",
capabilities: ["environment.drivers.register"],
environmentDrivers: [
{
driverKey: "e2b",
kind: "sandbox_provider",
displayName: "E2B Cloud Sandbox",
},
],
},
lastError: null,
});
mockPluginsApi.get.mockResolvedValue(basePlugin());
mockPluginsApi.dashboard.mockResolvedValue(null);
mockPluginsApi.health.mockResolvedValue({ pluginId: "plugin-1", status: "ready", healthy: true, checks: [] });
mockPluginsApi.logs.mockResolvedValue([]);
mockPluginsApi.listLocalFolders.mockResolvedValue({
pluginId: "plugin-1",
companyId: "company-1",
declarations: [],
folders: [],
});
});
afterEach(() => {
@@ -98,20 +162,7 @@ describe("PluginSettings", () => {
});
it("routes environment-provider plugins to company environments when they have no instance config", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<PluginSettings />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const root = await renderSettings(container);
expect(container.textContent).toContain("Configure this plugin from Company Environments.");
expect(container.textContent).toContain("company-scoped instead of instance-global");
@@ -122,4 +173,165 @@ describe("PluginSettings", () => {
root.unmount();
});
});
it("renders unconfigured manifest local folders with required paths", async () => {
const declaration = wikiFolderDeclaration();
mockPluginsApi.get.mockResolvedValue(basePlugin({
pluginKey: "paperclipai.plugin-llm-wiki",
packageName: "@paperclipai/plugin-llm-wiki",
status: "ready",
manifestJson: {
displayName: "LLM Wiki",
version: "0.1.0",
description: "Local-file LLM Wiki plugin.",
author: "Paperclip",
capabilities: ["local.folders"],
localFolders: [declaration],
},
}));
mockPluginsApi.listLocalFolders.mockResolvedValue({
pluginId: "plugin-1",
companyId: "company-1",
declarations: [declaration],
folders: [folderStatus()],
});
const root = await renderSettings(container);
expect(container.textContent).toContain("Local folders");
expect(container.textContent).toContain("Wiki root");
expect(container.textContent).toContain("Needs attention");
expect(container.textContent).toContain("No local folder path is configured.");
expect(container.textContent).toContain("Missing directories: raw, wiki");
expect(container.textContent).toContain("Missing files: WIKI.md, index.md");
await act(async () => {
root.unmount();
});
});
it("renders invalid configured folders with validation problems", async () => {
const declaration = wikiFolderDeclaration();
mockPluginsApi.get.mockResolvedValue(basePlugin({
manifestJson: {
displayName: "LLM Wiki",
version: "0.1.0",
description: "Local-file LLM Wiki plugin.",
author: "Paperclip",
capabilities: ["local.folders"],
localFolders: [declaration],
},
}));
mockPluginsApi.listLocalFolders.mockResolvedValue({
pluginId: "plugin-1",
companyId: "company-1",
declarations: [declaration],
folders: [folderStatus({
configured: true,
path: "/tmp/wiki",
realPath: "/tmp/wiki",
readable: true,
writable: true,
missingDirectories: [],
missingFiles: ["WIKI.md"],
problems: [{ code: "missing_file", message: "Required file is missing.", path: "WIKI.md" }],
})],
});
const root = await renderSettings(container);
expect(container.textContent).toContain("/tmp/wiki");
expect(container.textContent).toContain("ReadableYes");
expect(container.textContent).toContain("WritableYes");
expect(container.textContent).toContain("Validation problems");
expect(container.textContent).toContain("Required file is missing.");
expect(container.textContent).toContain("Missing files: WIKI.md");
await act(async () => {
root.unmount();
});
});
it("does not render required paths as present when the configured root cannot be inspected", async () => {
const declaration = wikiFolderDeclaration();
mockPluginsApi.get.mockResolvedValue(basePlugin({
manifestJson: {
displayName: "LLM Wiki",
version: "0.1.0",
description: "Local-file LLM Wiki plugin.",
author: "Paperclip",
capabilities: ["local.folders"],
localFolders: [declaration],
},
}));
mockPluginsApi.listLocalFolders.mockResolvedValue({
pluginId: "plugin-1",
companyId: "company-1",
declarations: [declaration],
folders: [folderStatus({
configured: true,
path: "/tmp/wiki-missing",
readable: false,
writable: false,
missingDirectories: [],
missingFiles: [],
problems: [{ code: "missing", message: "Configured local folder cannot be inspected.", path: "/tmp/wiki-missing" }],
})],
});
const root = await renderSettings(container);
expect(container.textContent).toContain("Configured local folder cannot be inspected.");
expect(container.textContent).toContain("Not inspected");
expect(container.textContent).toContain("Configured root was not inspected.");
expect(container.textContent).not.toContain("Present");
await act(async () => {
root.unmount();
});
});
it("renders healthy folders without validation problems", async () => {
const declaration = wikiFolderDeclaration();
mockPluginsApi.get.mockResolvedValue(basePlugin({
manifestJson: {
displayName: "LLM Wiki",
version: "0.1.0",
description: "Local-file LLM Wiki plugin.",
author: "Paperclip",
capabilities: ["local.folders"],
localFolders: [declaration],
},
}));
mockPluginsApi.listLocalFolders.mockResolvedValue({
pluginId: "plugin-1",
companyId: "company-1",
declarations: [declaration],
folders: [folderStatus({
configured: true,
path: "/tmp/wiki",
realPath: "/private/tmp/wiki",
readable: true,
writable: true,
missingDirectories: [],
missingFiles: [],
healthy: true,
problems: [],
})],
});
const root = await renderSettings(container);
expect(container.textContent).toContain("Healthy");
expect(container.textContent).toContain("Configured path");
expect(container.textContent).toContain("/tmp/wiki");
expect(container.textContent).toContain("ReadableYes");
expect(container.textContent).toContain("WritableYes");
expect(container.textContent).toContain("Present");
expect(container.textContent).not.toContain("Validation problems");
await act(async () => {
root.unmount();
});
});
});
+359 -4
View File
@@ -1,14 +1,16 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle } from "lucide-react";
import { Puzzle, ArrowLeft, ShieldAlert, ActivitySquare, CheckCircle, XCircle, Loader2, Clock, Cpu, Webhook, CalendarClock, AlertTriangle, FolderOpen, Save } from "lucide-react";
import type { PluginLocalFolderDeclaration } from "@paperclipai/shared";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { Link, Navigate, useParams } from "@/lib/router";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { pluginsApi } from "@/api/plugins";
import { pluginsApi, type PluginLocalFolderStatus } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import {
Card,
CardContent,
@@ -143,6 +145,8 @@ export function PluginSettings() {
const pluginDescription = plugin.manifestJson.description || "No description provided.";
const pluginCapabilities = plugin.manifestJson.capabilities ?? [];
const environmentDrivers = plugin.manifestJson.environmentDrivers ?? [];
const localFolderDeclarations = plugin.manifestJson.localFolders ?? [];
const hasLocalFolders = localFolderDeclarations.length > 0;
const environmentDriverNames = environmentDrivers
.map((driver) => driver.displayName?.trim() || driver.driverKey)
.filter((name, index, values) => values.indexOf(name) === index);
@@ -217,6 +221,13 @@ export function PluginSettings() {
<div className="space-y-1">
<h2 className="text-base font-semibold">Settings</h2>
</div>
{hasLocalFolders ? (
<PluginLocalFoldersSettings
pluginId={pluginId!}
companyId={selectedCompanyId}
declarations={localFolderDeclarations}
/>
) : null}
{hasCustomSettingsPage ? (
<div className="space-y-3">
{pluginSlots.map((slot) => (
@@ -253,11 +264,11 @@ export function PluginSettings() {
</Link>
</div>
</div>
) : (
) : !hasLocalFolders ? (
<p className="text-sm text-muted-foreground">
This plugin does not require any settings.
</p>
)}
) : null}
</section>
</div>
</TabsContent>
@@ -558,6 +569,350 @@ export function PluginSettings() {
);
}
// ---------------------------------------------------------------------------
// PluginLocalFoldersSettings — host-managed company-scoped folders
// ---------------------------------------------------------------------------
interface PluginLocalFoldersSettingsProps {
pluginId: string;
companyId: string | null;
declarations: PluginLocalFolderDeclaration[];
}
function PluginLocalFoldersSettings({ pluginId, companyId, declarations }: PluginLocalFoldersSettingsProps) {
const { data, isLoading, error } = useQuery({
queryKey: companyId
? queryKeys.plugins.localFolders(pluginId, companyId)
: ["plugins", pluginId, "companies", "none", "local-folders"],
queryFn: () => pluginsApi.listLocalFolders(pluginId, companyId!),
enabled: !!companyId,
});
const statusByKey = new Map((data?.folders ?? []).map((folder) => [folder.folderKey, folder]));
if (!companyId) {
return (
<div className="rounded-md border border-border/60 bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Select a company to configure this plugin's local folders.
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-medium">Local folders</h3>
</div>
{error ? (
<div className="rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{(error as Error).message || "Failed to load local folder settings."}
</div>
) : null}
{isLoading ? (
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading local folders...
</div>
) : (
<div className="space-y-3">
{declarations.map((declaration) => (
<PluginLocalFolderRow
key={declaration.folderKey}
pluginId={pluginId}
companyId={companyId}
declaration={declaration}
status={statusByKey.get(declaration.folderKey)}
/>
))}
</div>
)}
</div>
);
}
interface PluginLocalFolderRowProps {
pluginId: string;
companyId: string;
declaration: PluginLocalFolderDeclaration;
status?: PluginLocalFolderStatus;
}
function PluginLocalFolderRow({ pluginId, companyId, declaration, status }: PluginLocalFolderRowProps) {
const queryClient = useQueryClient();
const serverPath = status?.path ?? "";
const [pathValue, setPathValue] = useState(serverPath);
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
useEffect(() => {
setPathValue(serverPath);
setMessage(null);
}, [serverPath, declaration.folderKey]);
const saveMutation = useMutation({
mutationFn: (path: string) =>
pluginsApi.configureLocalFolder(pluginId, companyId, declaration.folderKey, {
path,
access: declaration.access,
requiredDirectories: declaration.requiredDirectories,
requiredFiles: declaration.requiredFiles,
}),
onSuccess: (nextStatus) => {
setMessage({
type: nextStatus.healthy ? "success" : "error",
text: nextStatus.healthy
? "Local folder saved."
: "Local folder saved, but validation still needs attention.",
});
queryClient.invalidateQueries({ queryKey: queryKeys.plugins.localFolders(pluginId, companyId) });
},
onError: (err: Error) => {
setMessage({ type: "error", text: err.message || "Failed to save local folder." });
},
});
const trimmedPath = pathValue.trim();
const isDirty = trimmedPath !== serverPath;
const access = status?.access ?? declaration.access ?? "readWrite";
const handleSave = useCallback(() => {
if (!trimmedPath) {
setMessage({ type: "error", text: "Local folder path is required." });
return;
}
if (!isLikelyAbsolutePath(trimmedPath)) {
setMessage({ type: "error", text: "Local folder must be a full absolute path." });
return;
}
setMessage(null);
saveMutation.mutate(trimmedPath);
}, [saveMutation, trimmedPath]);
return (
<div className="space-y-4 rounded-md border border-border/70 bg-background px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-sm font-medium">{declaration.displayName}</h4>
<Badge variant="outline" className="font-mono text-[10px]">
{declaration.folderKey}
</Badge>
<Badge variant={status?.healthy ? "default" : "secondary"}>
{status?.healthy ? "Healthy" : "Needs attention"}
</Badge>
</div>
{declaration.description ? (
<p className="max-w-3xl text-sm leading-5 text-muted-foreground">
{declaration.description}
</p>
) : null}
</div>
<Badge variant={access === "readWrite" ? "default" : "outline"}>
{access === "readWrite" ? "Read/write" : "Read only"}
</Badge>
</div>
<div className="grid gap-3 text-sm sm:grid-cols-3">
<FolderStatusMetric label="Configured" value={status?.configured ? "Yes" : "No"} ok={!!status?.configured} />
<FolderStatusMetric label="Readable" value={status?.readable ? "Yes" : "No"} ok={!!status?.readable} />
<FolderStatusMetric
label="Writable"
value={access === "read" ? "Not requested" : status?.writable ? "Yes" : "No"}
ok={access === "read" || !!status?.writable}
/>
</div>
{status?.path ? (
<div className="space-y-1 text-sm">
<div className="text-xs font-medium text-muted-foreground">Configured path</div>
<div className="break-all rounded-md bg-muted/60 px-2 py-1.5 font-mono text-xs text-foreground">
{status.path}
</div>
</div>
) : null}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground" htmlFor={`local-folder-${declaration.folderKey}`}>
Local folder path
</label>
<div className="flex items-center gap-2">
<input
id={`local-folder-${declaration.folderKey}`}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2.5 py-1.5 font-mono text-sm outline-none focus:border-foreground/40 focus:ring-2 focus:ring-ring/20"
value={pathValue}
onChange={(event) => {
setPathValue(event.target.value);
setMessage(null);
}}
placeholder="/absolute/path/to/folder"
/>
<ChoosePathButton className="h-8" />
<Button
size="sm"
onClick={handleSave}
disabled={saveMutation.isPending || !isDirty}
>
{saveMutation.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Save className="h-3.5 w-3.5" />
)}
Save
</Button>
</div>
</div>
<FolderRequirements status={status} declaration={declaration} />
{status?.problems?.length ? (
<div className="space-y-2 rounded-md border border-destructive/20 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="font-medium">Validation problems</div>
<ul className="space-y-1">
{status.problems.map((problem, index) => (
<li key={`${problem.code}:${problem.path ?? ""}:${index}`}>
{problem.message}
{problem.path ? <span className="font-mono"> {problem.path}</span> : null}
</li>
))}
</ul>
</div>
) : null}
{message ? (
<div
className={`rounded-md border px-3 py-2 text-sm ${
message.type === "success"
? "border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/30 dark:text-green-400"
: "border-destructive/20 bg-destructive/10 text-destructive"
}`}
>
{message.text}
</div>
) : null}
</div>
);
}
function FolderStatusMetric({ label, value, ok }: { label: string; value: string; ok: boolean }) {
return (
<div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2">
<span className="text-muted-foreground">{label}</span>
<Badge variant={ok ? "default" : "secondary"}>{value}</Badge>
</div>
);
}
function FolderRequirements({
status,
declaration,
}: {
status?: PluginLocalFolderStatus;
declaration: PluginLocalFolderDeclaration;
}) {
const requiredDirectories = status?.requiredDirectories ?? declaration.requiredDirectories ?? [];
const requiredFiles = status?.requiredFiles ?? declaration.requiredFiles ?? [];
const missingDirectories = status?.missingDirectories ?? requiredDirectories;
const missingFiles = status?.missingFiles ?? requiredFiles;
const rootNotInspected = isRootNotInspected(status);
if (requiredDirectories.length === 0 && requiredFiles.length === 0) return null;
return (
<div className="grid gap-3 text-sm md:grid-cols-2">
<RequirementList
title="Required directories"
items={requiredDirectories}
missingItems={missingDirectories}
missingLabel="Missing directories"
inspectionUnavailable={rootNotInspected}
/>
<RequirementList
title="Required files"
items={requiredFiles}
missingItems={missingFiles}
missingLabel="Missing files"
inspectionUnavailable={rootNotInspected}
/>
</div>
);
}
function isRootNotInspected(status?: PluginLocalFolderStatus) {
if (!status?.configured || status.readable) return false;
return status.problems.some((problem) =>
problem.code === "missing" || problem.code === "not_readable" || problem.code === "not_directory"
);
}
function RequirementList({
title,
items,
missingItems,
missingLabel,
inspectionUnavailable,
}: {
title: string;
items: string[];
missingItems: string[];
missingLabel: string;
inspectionUnavailable?: boolean;
}) {
return (
<div className="space-y-2 rounded-md border border-border/60 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">{title}</span>
{inspectionUnavailable ? (
<Badge variant="secondary" className="text-[10px]">
Not inspected
</Badge>
) : missingItems.length > 0 ? (
<Badge variant="destructive" className="text-[10px]">
{missingItems.length} missing
</Badge>
) : (
<Badge variant="outline" className="text-[10px]">Present</Badge>
)}
</div>
{items.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{items.map((item) => {
const missing = missingItems.includes(item);
return (
<span
key={item}
className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${
inspectionUnavailable
? "border-amber-300/60 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/30 dark:text-amber-300"
: missing
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-border bg-muted/50 text-foreground/80"
}`}
>
{item}
</span>
);
})}
</div>
) : (
<p className="text-xs text-muted-foreground">None declared.</p>
)}
{inspectionUnavailable ? (
<p className="text-xs text-amber-700 dark:text-amber-300">Configured root was not inspected.</p>
) : missingItems.length > 0 ? (
<p className="text-xs text-destructive">{missingLabel}: {missingItems.join(", ")}</p>
) : null}
</div>
);
}
function isLikelyAbsolutePath(pathValue: string) {
return (
pathValue.startsWith("/") ||
/^[A-Za-z]:[\\/]/.test(pathValue) ||
pathValue.startsWith("\\\\")
);
}
// ---------------------------------------------------------------------------
// PluginConfigForm — auto-generated form for instanceConfigSchema
// ---------------------------------------------------------------------------
+187
View File
@@ -0,0 +1,187 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectDetail } from "./ProjectDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockProjectsApi = vi.hoisted(() => ({
get: vi.fn(),
list: vi.fn(),
update: vi.fn(),
}));
const mockIssuesApi = vi.hoisted(() => ({
list: vi.fn(),
update: vi.fn(),
}));
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() }));
const mockBudgetsApi = vi.hoisted(() => ({ overview: vi.fn(), upsertPolicy: vi.fn() }));
const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() }));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockIssuesList = vi.hoisted(() => vi.fn());
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
vi.mock("../api/budgets", () => ({ budgetsApi: mockBudgetsApi }));
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
useLocation: () => ({ pathname: "/projects/project-1/plugin-operations", search: "", hash: "", state: null }),
useNavigate: () => mockNavigate,
useParams: () => ({ projectId: "project-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
setSelectedCompanyId: vi.fn(),
}),
}));
vi.mock("../context/PanelContext", () => ({ usePanel: () => ({ closePanel: vi.fn() }) }));
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: () => null,
PluginSlotOutlet: () => null,
usePluginSlots: () => ({ slots: [], isLoading: false }),
}));
vi.mock("@/plugins/launchers", () => ({ PluginLauncherOutlet: () => null }));
vi.mock("../components/ProjectProperties", () => ({
ProjectProperties: () => <div data-testid="project-properties" />,
}));
vi.mock("../components/BudgetPolicyCard", () => ({
BudgetPolicyCard: () => <div data-testid="budget-policy-card" />,
}));
vi.mock("../components/InlineEditor", () => ({
InlineEditor: ({ value, placeholder }: { value?: string; placeholder?: string }) => (
<span>{value || placeholder || null}</span>
),
}));
vi.mock("../components/ProjectWorkspacesContent", () => ({
ProjectWorkspacesContent: () => <div data-testid="project-workspaces" />,
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
<div>{items.map((item) => <button key={item.value}>{item.label}</button>)}</div>
),
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: (props: unknown) => {
mockIssuesList(props);
return <div data-testid="issues-list" />;
},
}));
function project(overrides: Partial<Project> = {}): Project {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Managed Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-1",
effectiveLocalFolder: "/tmp/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
managedByPlugin: {
id: "managed-1",
pluginId: "plugin-1",
pluginKey: "paperclip.missions",
pluginDisplayName: "Missions",
resourceKind: "project",
resourceKey: "operations",
defaultsJson: {},
createdAt: now,
updatedAt: now,
},
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe("ProjectDetail", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockProjectsApi.get.mockResolvedValue(project());
mockProjectsApi.list.mockResolvedValue([project()]);
mockIssuesApi.list.mockResolvedValue([]);
mockAgentsApi.list.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
mockBudgetsApi.overview.mockResolvedValue({ policies: [] });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
it("shows managed plugin affordances and filters the operations tab by plugin origin", async () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ProjectDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(container.textContent).toContain("Managed by Missions");
expect(container.textContent).toContain("Plugin operations");
expect(mockIssuesApi.list).toHaveBeenCalledWith("company-1", {
projectId: "project-1",
originKindPrefix: "plugin:paperclip.missions",
});
});
});
+87 -1
View File
@@ -33,7 +33,7 @@ import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slo
/* ── Top-level tab types ── */
type ProjectBaseTab = "overview" | "list" | "workspaces" | "configuration" | "budget";
type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget";
type ProjectPluginTab = `plugin:${string}`;
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
@@ -50,6 +50,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
if (tab === "configuration") return "configuration";
if (tab === "budget") return "budget";
if (tab === "issues") return "list";
if (tab === "plugin-operations") return "plugin-operations";
if (tab === "workspaces") return "workspaces";
return null;
}
@@ -208,6 +209,67 @@ function ProjectIssuesList({ projectId, companyId }: { projectId: string; compan
);
}
function ProjectPluginOperationsList({
projectId,
companyId,
pluginKey,
}: {
projectId: string;
companyId: string;
pluginKey: string;
}) {
const queryClient = useQueryClient();
const originKindPrefix = `plugin:${pluginKey}`;
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
enabled: !!companyId,
});
const { data: projects } = useQuery({
queryKey: queryKeys.projects.list(companyId),
queryFn: () => projectsApi.list(companyId),
enabled: !!companyId,
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(companyId),
queryFn: () => heartbeatsApi.liveRunsForCompany(companyId),
enabled: !!companyId,
refetchInterval: 5000,
});
const liveIssueIds = useMemo(() => collectLiveIssueIds(liveRuns), [liveRuns]);
const { data: issues, isLoading, error } = useQuery({
queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix),
queryFn: () => issuesApi.list(companyId, { projectId, originKindPrefix }),
enabled: !!companyId && !!projectId,
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listPluginOperationsByProject(companyId, projectId, originKindPrefix) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.listByProject(companyId, projectId) });
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
},
});
return (
<IssuesList
issues={issues ?? []}
isLoading={isLoading}
error={error as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
projectId={projectId}
viewStateKey={`paperclip:project-plugin-operations-view:${pluginKey}`}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
);
}
/* ── Main project page ── */
export function ProjectDetail() {
@@ -390,6 +452,10 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}/budget`, { replace: true });
return;
}
if (activeTab === "plugin-operations") {
navigate(`/projects/${canonicalProjectRef}/plugin-operations`, { replace: true });
return;
}
if (activeTab === "workspaces") {
navigate(`/projects/${canonicalProjectRef}/workspaces`, { replace: true });
return;
@@ -523,6 +589,9 @@ export function ProjectDetail() {
if (cachedTab === "budget") {
return <Navigate to={`/projects/${canonicalProjectRef}/budget`} replace />;
}
if (cachedTab === "plugin-operations" && project?.managedByPlugin) {
return <Navigate to={`/projects/${canonicalProjectRef}/plugin-operations`} replace />;
}
if (cachedTab === "workspaces" && workspaceTabDecisionLoaded && showWorkspacesTab) {
return <Navigate to={`/projects/${canonicalProjectRef}/workspaces`} replace />;
}
@@ -554,6 +623,8 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}/workspaces`);
} else if (tab === "budget") {
navigate(`/projects/${canonicalProjectRef}/budget`);
} else if (tab === "plugin-operations") {
navigate(`/projects/${canonicalProjectRef}/plugin-operations`);
} else if (tab === "configuration") {
navigate(`/projects/${canonicalProjectRef}/configuration`);
} else {
@@ -583,6 +654,12 @@ export function ProjectDetail() {
Paused by budget hard stop
</div>
) : null}
{project.managedByPlugin ? (
<div className="inline-flex items-center gap-2 rounded-full border border-border bg-muted px-3 py-1 text-[11px] font-medium text-muted-foreground">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: project.color ?? "#6366f1" }} />
Managed by {project.managedByPlugin.pluginDisplayName}
</div>
) : null}
</div>
</div>
@@ -622,6 +699,7 @@ export function ProjectDetail() {
items={[
{ value: "list", label: "Issues" },
{ value: "overview", label: "Overview" },
...(project.managedByPlugin ? [{ value: "plugin-operations", label: "Plugin operations" }] : []),
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
{ value: "configuration", label: "Configuration" },
{ value: "budget", label: "Budget" },
@@ -651,6 +729,14 @@ export function ProjectDetail() {
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />
)}
{activeTab === "plugin-operations" && project?.id && resolvedCompanyId && project.managedByPlugin && (
<ProjectPluginOperationsList
projectId={project.id}
companyId={resolvedCompanyId}
pluginKey={project.managedByPlugin.pluginKey}
/>
)}
{activeTab === "workspaces" ? (
workspaceTabDecisionLoaded ? (
workspaceTabError ? (
+215 -63
View File
@@ -7,6 +7,7 @@ import {
ChevronRight,
Clock3,
Copy,
History as HistoryIcon,
Play,
RefreshCw,
Repeat,
@@ -15,7 +16,12 @@ import {
Webhook,
Zap,
} from "lucide-react";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse } from "../api/routines";
import { ApiError } from "../api/client";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines";
import {
RoutineHistoryTab,
type RoutineHistoryDirtyFieldDescriptor,
} from "../components/RoutineHistoryTab";
import { heartbeatsApi } from "../api/heartbeats";
import { LiveRunWidget } from "../components/LiveRunWidget";
import { agentsApi } from "../api/agents";
@@ -57,13 +63,13 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import type { RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const routineTabs = ["triggers", "runs", "activity"] as const;
const routineTabs = ["triggers", "runs", "activity", "history"] as const;
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
always_enqueue: "Queue every trigger occurrence, even if several runs stack up.",
@@ -85,8 +91,10 @@ type RoutineTab = (typeof routineTabs)[number];
type SecretMessage = {
title: string;
webhookUrl: string;
webhookSecret: string;
entries: Array<{
webhookUrl: string;
webhookSecret: string;
}>;
};
function autoResizeTextarea(element: HTMLTextAreaElement | null) {
@@ -279,6 +287,7 @@ export function RoutineDetail() {
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
const [secretMessage, setSecretMessage] = useState<SecretMessage | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [saveConflict, setSaveConflict] = useState(false);
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
const [newTrigger, setNewTrigger] = useState({
kind: "schedule",
@@ -374,19 +383,34 @@ export function RoutineDetail() {
: null,
[routine],
);
const isEditDirty = useMemo(() => {
if (!routineDefaults) return false;
return (
editDraft.title !== routineDefaults.title ||
editDraft.description !== routineDefaults.description ||
editDraft.projectId !== routineDefaults.projectId ||
editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId ||
editDraft.priority !== routineDefaults.priority ||
editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy ||
editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy ||
JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)
);
const dirtyFields = useMemo<RoutineHistoryDirtyFieldDescriptor[]>(() => {
if (!routineDefaults) return [];
const result: RoutineHistoryDirtyFieldDescriptor[] = [];
if (editDraft.title !== routineDefaults.title) result.push({ key: "title", label: "the title" });
if (editDraft.description !== routineDefaults.description) {
result.push({ key: "description", label: "the description" });
}
if (editDraft.projectId !== routineDefaults.projectId) {
result.push({ key: "projectId", label: "the project" });
}
if (editDraft.assigneeAgentId !== routineDefaults.assigneeAgentId) {
result.push({ key: "assigneeAgentId", label: "the default agent" });
}
if (editDraft.priority !== routineDefaults.priority) {
result.push({ key: "priority", label: "the priority" });
}
if (editDraft.concurrencyPolicy !== routineDefaults.concurrencyPolicy) {
result.push({ key: "concurrencyPolicy", label: "the concurrency policy" });
}
if (editDraft.catchUpPolicy !== routineDefaults.catchUpPolicy) {
result.push({ key: "catchUpPolicy", label: "the catch-up policy" });
}
if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) {
result.push({ key: "variables", label: "the variables" });
}
return result;
}, [editDraft, routineDefaults]);
const isEditDirty = dirtyFields.length > 0;
useEffect(() => {
if (!routine) return;
@@ -437,16 +461,32 @@ export function RoutineDetail() {
const saveRoutine = useMutation({
mutationFn: () => {
return routinesApi.update(routineId!, buildRoutineMutationPayload(editDraft));
const payload = buildRoutineMutationPayload(editDraft);
const baseRevisionId = routine?.latestRevisionId ?? null;
return routinesApi.update(routineId!, {
...payload,
...(baseRevisionId ? { baseRevisionId } : {}),
});
},
onSuccess: async () => {
setSaveConflict(false);
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.activity(selectedCompanyId!, routineId!) }),
queryClient.invalidateQueries({ queryKey: queryKeys.routines.revisions(routineId!) }),
]);
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409) {
setSaveConflict(true);
pushToast({
title: "Routine changed",
body: "Someone else updated this routine. Reload to see the latest revision.",
tone: "warn",
});
return;
}
pushToast({
title: "Failed to save routine",
body: error instanceof Error ? error.message : "Paperclip could not save the routine.",
@@ -533,8 +573,10 @@ export function RoutineDetail() {
if (result.secretMaterial) {
setSecretMessage({
title: "Webhook trigger created",
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
entries: [{
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
}],
});
} else {
pushToast({
@@ -608,8 +650,10 @@ export function RoutineDetail() {
onSuccess: async (result) => {
setSecretMessage({
title: "Webhook secret rotated",
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
entries: [{
webhookUrl: result.secretMaterial.webhookUrl,
webhookSecret: result.secretMaterial.webhookSecret,
}],
});
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
@@ -702,36 +746,44 @@ export function RoutineDetail() {
<div className="max-w-2xl space-y-6">
{/* Header: editable title + actions */}
<div className="flex items-start gap-4">
<textarea
ref={titleInputRef}
className="flex-1 min-w-0 resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={editDraft.title}
onChange={(event) => {
setEditDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
return;
}
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (editDraft.assigneeAgentId) {
if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
<div className="min-w-0 flex-1 space-y-2">
<textarea
ref={titleInputRef}
className="w-full resize-none overflow-hidden bg-transparent text-xl font-bold outline-none placeholder:text-muted-foreground/50"
placeholder="Routine title"
rows={1}
value={editDraft.title}
onChange={(event) => {
setEditDraft((current) => ({ ...current, title: event.target.value }));
autoResizeTextarea(event.target);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.metaKey && !event.ctrlKey && !event.nativeEvent.isComposing) {
event.preventDefault();
descriptionEditorRef.current?.focus();
return;
}
}
}}
/>
if (event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
if (editDraft.assigneeAgentId) {
if (editDraft.projectId) {
descriptionEditorRef.current?.focus();
} else {
projectSelectorRef.current?.focus();
}
} else {
assigneeSelectorRef.current?.focus();
}
}
}}
/>
{routine.managedByPlugin ? (
<Badge variant="outline" className="gap-1 text-xs text-muted-foreground">
Managed by {routine.managedByPlugin.pluginDisplayName}
<span className="font-mono text-[10px]">{routine.managedByPlugin.resourceKey}</span>
</Badge>
) : null}
</div>
<div className="flex shrink-0 items-center gap-3 pt-1">
<RunButton
onClick={() => {
@@ -769,19 +821,58 @@ export function RoutineDetail() {
<p className="font-medium">{secretMessage.title}</p>
<p className="text-xs text-muted-foreground">Save this now. Paperclip will not show the secret value again.</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", secretMessage.webhookUrl)}>
<Copy className="h-3.5 w-3.5 mr-1" />
URL
</Button>
<div className="space-y-3">
{secretMessage.entries.map((entry, index) => (
<div key={`${entry.webhookUrl}-${index}`} className="space-y-2">
{secretMessage.entries.length > 1 && (
<p className="text-xs font-medium text-muted-foreground">
Webhook trigger {index + 1} of {secretMessage.entries.length}
</p>
)}
<div className="flex items-center gap-2">
<Input value={entry.webhookUrl} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook URL", entry.webhookUrl)}>
<Copy className="h-3.5 w-3.5 mr-1" />
URL
</Button>
</div>
<div className="flex items-center gap-2">
<Input value={entry.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", entry.webhookSecret)}>
<Copy className="h-3.5 w-3.5 mr-1" />
Secret
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* Save conflict banner */}
{saveConflict && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<p className="font-medium text-amber-200">Out of date</p>
<p className="text-xs text-muted-foreground">
This routine changed while you were editing. Reload to merge the latest revision before
saving again.
</p>
</div>
<div className="flex items-center gap-2">
<Input value={secretMessage.webhookSecret} readOnly className="flex-1" />
<Button variant="outline" size="sm" onClick={() => copySecretValue("Webhook secret", secretMessage.webhookSecret)}>
<Copy className="h-3.5 w-3.5 mr-1" />
Secret
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setSaveConflict(false);
if (routineDefaults) {
setEditDraft(routineDefaults);
}
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) });
}}
>
Reload latest
</Button>
</div>
</div>
@@ -991,6 +1082,10 @@ export function RoutineDetail() {
<ActivityIcon className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5">
<HistoryIcon className="h-3.5 w-3.5" />
History
</TabsTrigger>
</TabsList>
<TabsContent value="triggers" className="space-y-4">
@@ -1130,6 +1225,63 @@ export function RoutineDetail() {
</div>
)}
</TabsContent>
<TabsContent value="history">
<RoutineHistoryTab
routine={routine}
isEditDirty={isEditDirty}
dirtyFields={dirtyFields}
onDiscardEdits={() => {
if (routineDefaults) setEditDraft(routineDefaults);
}}
onSaveEdits={() => {
if (!saveRoutine.isPending && editDraft.title.trim()) {
saveRoutine.mutate();
}
}}
agents={agentById}
projects={projectById}
onRestoreSecretMaterials={(response: RestoreRoutineRevisionResponse) => {
if (response.secretMaterials.length > 0) {
setSecretMessage({
title: response.secretMaterials.length === 1
? "Webhook trigger restored"
: `${response.secretMaterials.length} webhook triggers restored`,
entries: response.secretMaterials.map((recreated) => ({
webhookUrl: recreated.webhookUrl,
webhookSecret: recreated.webhookSecret,
})),
});
}
}}
onRestored={(response: RestoreRoutineRevisionResponse) => {
setSaveConflict(false);
queryClient.setQueryData<RoutineDetailType | undefined>(
queryKeys.routines.detail(routineId!),
(prev) =>
prev
? {
...prev,
...response.routine,
latestRevisionId: response.revision.id,
latestRevisionNumber: response.revision.revisionNumber,
}
: prev,
);
setEditDraft({
title: response.routine.title,
description: response.routine.description ?? "",
projectId: response.routine.projectId ?? "",
assigneeAgentId: response.routine.assigneeAgentId ?? "",
priority: response.routine.priority,
concurrencyPolicy: response.routine.concurrencyPolicy,
catchUpPolicy: response.routine.catchUpPolicy,
variables: response.routine.variables,
});
hydratedRoutineIdRef.current = response.routine.id;
}}
/>
</TabsContent>
</Tabs>
<RoutineRunVariablesDialog
+48 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { act } from "react";
import { act, type AnchorHTMLAttributes, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
@@ -18,6 +18,11 @@ const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
));
vi.mock("@/lib/router", () => ({
Link: ({ to, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string; children: ReactNode }) => (
<a href={to} {...props}>
{children}
</a>
),
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }),
useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()],
@@ -247,6 +252,8 @@ function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
latestRevisionId: null,
latestRevisionNumber: 1,
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
@@ -306,6 +313,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
lastActivityAt: new Date("2026-04-01T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
workMode: overrides.workMode ?? "standard",
};
}
@@ -448,6 +456,45 @@ describe("Routines page", () => {
});
});
it("shows a row-level run now button on the routines table", async () => {
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
issuesListMock.mockResolvedValue([]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
let runNowButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Run now"),
);
for (let attempts = 0; attempts < 5 && !runNowButton; attempts += 1) {
await act(async () => {
await flush();
});
runNowButton = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Run now"),
);
}
expect(runNowButton).toBeTruthy();
await act(async () => {
root.unmount();
});
});
it("passes company mention options to the routine description editor", async () => {
routinesListMock.mockResolvedValue([]);
issuesListMock.mockResolvedValue([]);
+3 -135
View File
@@ -1,7 +1,7 @@
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate, useSearchParams } from "@/lib/router";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
@@ -18,7 +18,6 @@ import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
@@ -26,6 +25,7 @@ import { PageTabBar } from "../components/PageTabBar";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "../components/MarkdownEditor";
import { RoutineListRow, nextRoutineStatus } from "../components/RoutineList";
import {
RoutineRunVariablesDialog,
type RoutineRunDialogSubmitData,
@@ -35,13 +35,6 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
@@ -71,16 +64,6 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
element.style.height = `${element.scrollHeight}px`;
}
function formatLastRunTimestamp(value: Date | string | null | undefined) {
if (!value) return "Never";
return new Date(value).toLocaleString();
}
function nextRoutineStatus(currentStatus: string, enabled: boolean) {
if (currentStatus === "archived" && enabled) return "active";
return enabled ? "active" : "paused";
}
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "none" | "project" | "assignee";
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
@@ -130,11 +113,6 @@ function compareNullableText(left: string | null | undefined, right: string | nu
return (left ?? "").localeCompare(right ?? "", undefined, { sensitivity: "base" });
}
function formatRoutineRunStatus(value: string | null | undefined) {
if (!value) return null;
return value.replaceAll("_", " ");
}
function buildRoutineMutationPayload(input: {
title: string;
description: string;
@@ -221,117 +199,6 @@ function buildRoutinesTabHref(tab: RoutinesTab) {
return tab === "runs" ? "/routines?tab=runs" : "/routines";
}
function RoutineListRow({
routine,
projectById,
agentById,
runningRoutineId,
statusMutationRoutineId,
href,
onRunNow,
onToggleEnabled,
onToggleArchived,
}: {
routine: RoutineListItem;
projectById: Map<string, { name: string; color?: string | null }>;
agentById: Map<string, { name: string; icon?: string | null }>;
runningRoutineId: string | null;
statusMutationRoutineId: string | null;
href: string;
onRunNow: (routine: RoutineListItem) => void;
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
onToggleArchived: (routine: RoutineListItem) => void;
}) {
const enabled = routine.status === "active";
const isArchived = routine.status === "archived";
const isStatusPending = statusMutationRoutineId === routine.id;
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
const isDraft = !isArchived && !routine.assigneeAgentId;
return (
<Link
to={href}
className="group flex flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center no-underline text-inherit"
>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{routine.title}</span>
{(isArchived || routine.status === "paused" || isDraft) ? (
<span className="text-xs text-muted-foreground">
{isArchived ? "archived" : isDraft ? "draft" : "paused"}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span>{routine.projectId ? (project?.name ?? "Unknown project") : "No project"}</span>
</span>
<span className="flex items-center gap-2">
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
<span>{routine.assigneeAgentId ? (agent?.name ?? "Unknown agent") : "No default agent"}</span>
</span>
<span>
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
</span>
</div>
</div>
<div className="flex items-center gap-3" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }}>
<div className="flex items-center gap-3">
<ToggleSwitch
size="lg"
checked={enabled}
onCheckedChange={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
/>
<span className="w-12 text-xs text-muted-foreground">
{isArchived ? "Archived" : isDraft ? "Draft" : enabled ? "On" : "Off"}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={href}>Edit</Link>
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => onRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onToggleArchived(routine)}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Link>
);
}
export function Routines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -1050,6 +917,7 @@ export function Routines() {
runningRoutineId={runningRoutineId}
statusMutationRoutineId={statusMutationRoutineId}
href={`/routines/${routine.id}`}
runNowButton
onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived}
+358
View File
@@ -0,0 +1,358 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Search, buildSearchUrl } from "./Search";
const companyState = vi.hoisted(() => ({
selectedCompanyId: "company-1",
}));
const breadcrumbState = vi.hoisted(() => ({
setBreadcrumbs: vi.fn(),
}));
const dialogState = vi.hoisted(() => ({
openNewIssue: vi.fn(),
}));
const navigateMock = vi.hoisted(() => vi.fn());
const searchApiMock = vi.hoisted(() => ({
search: vi.fn(),
}));
const agentsApiMock = vi.hoisted(() => ({
list: vi.fn(),
}));
const projectsApiMock = vi.hoisted(() => ({
list: vi.fn(),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => companyState,
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => breadcrumbState,
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => dialogState,
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({ isMobile: false, setSidebarOpen: vi.fn() }),
}));
vi.mock("../api/search", () => ({
searchApi: searchApiMock,
}));
vi.mock("../api/agents", () => ({
agentsApi: agentsApiMock,
}));
vi.mock("../api/projects", () => ({
projectsApi: projectsApiMock,
}));
vi.mock("@/lib/router", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
useNavigate: () => navigateMock,
};
});
vi.mock("../components/StatusIcon", () => ({
StatusIcon: ({ status }: { status: string }) => <span data-status={status} />,
}));
vi.mock("../components/StatusBadge", () => ({
StatusBadge: ({ status }: { status: string }) => <span data-status-badge={status}>{status}</span>,
}));
vi.mock("../components/Identity", () => ({
Identity: ({ name }: { name: string }) => <span>{name}</span>,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await Promise.resolve();
});
}
async function waitForAssertion(assertion: () => void, attempts = 50) {
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flush();
}
}
throw lastError;
}
function renderSearch(initialPath: string, container: HTMLDivElement, node?: ReactNode) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/search" element={node ?? <Search />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
return { root, queryClient };
}
describe("buildSearchUrl", () => {
it("writes q and scope when provided", () => {
expect(buildSearchUrl("http://x/search", "auth flake", "comments")).toBe(
"/search?q=auth+flake&scope=comments",
);
});
it("clears q when empty and omits scope when scope=all", () => {
expect(buildSearchUrl("http://x/search?q=stale&scope=issues", "", "all")).toBe("/search");
});
it("preserves the existing pathname and hash", () => {
expect(buildSearchUrl("http://x/PAP/search?q=x#anchor", "y", "issues")).toBe(
"/PAP/search?q=y&scope=issues#anchor",
);
});
});
describe("Search page", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
breadcrumbState.setBreadcrumbs.mockReset();
dialogState.openNewIssue.mockReset();
navigateMock.mockReset();
searchApiMock.search.mockReset();
agentsApiMock.list.mockReset();
projectsApiMock.list.mockReset();
agentsApiMock.list.mockResolvedValue([]);
projectsApiMock.list.mockResolvedValue([]);
window.localStorage.clear();
});
afterEach(() => {
container.remove();
});
it("issues a search request when ?q is in the URL and renders the result", async () => {
searchApiMock.search.mockResolvedValueOnce({
query: "auth flake",
normalizedQuery: "auth flake",
scope: "all",
limit: 20,
offset: 0,
countsByType: { issue: 1, agent: 0, project: 0 },
hasMore: false,
results: [
{
id: "issue-1",
type: "issue",
score: 100,
title: "PAP-3142 Auth middleware flakes",
href: "/PAP/issues/PAP-3142",
matchedFields: ["title", "comment"],
sourceLabel: "Comment",
snippet: "we hit another flake",
snippets: [
{
field: "title",
label: "Title",
text: "Auth middleware flakes",
highlights: [{ start: 0, end: 4 }],
},
{
field: "comment",
label: "Comment",
text: "we hit another flake in the morning batch",
highlights: [{ start: 16, end: 21 }],
},
],
issue: {
id: "issue-1",
identifier: "PAP-3142",
title: "Auth middleware flakes",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
projectId: null,
updatedAt: new Date().toISOString(),
},
updatedAt: new Date().toISOString(),
},
],
});
const { root } = renderSearch("/search?q=auth+flake", container);
await waitForAssertion(() => {
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
q: "auth flake",
scope: "all",
limit: 20,
});
});
await waitForAssertion(() => {
expect(container.textContent).toContain("PAP-3142");
expect(container.textContent).toContain("Auth middleware flakes");
expect(container.textContent).toContain("1 result");
});
act(() => {
root.unmount();
});
});
it("debounces typing into the input and dispatches a search after the debounce window", async () => {
searchApiMock.search.mockResolvedValue({
query: "deflake",
normalizedQuery: "deflake",
scope: "all",
limit: 20,
offset: 0,
countsByType: { issue: 0, agent: 0, project: 0 },
hasMore: false,
results: [],
});
const { root } = renderSearch("/search", container);
const input = container.querySelector('input[aria-label="Search query"]') as HTMLInputElement;
expect(input).not.toBeNull();
act(() => {
const nativeSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)!.set!;
nativeSetter.call(input, "deflake");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
// The debounce hasn't fired yet, so no API call should be made synchronously.
expect(searchApiMock.search).not.toHaveBeenCalled();
await new Promise((resolve) => setTimeout(resolve, 350));
await waitForAssertion(() => {
expect(searchApiMock.search).toHaveBeenCalledWith("company-1", {
q: "deflake",
scope: "all",
limit: 20,
});
});
act(() => {
root.unmount();
});
});
it("auto-redirects an exact identifier match to the issue root, dropping any deep-link suffix", async () => {
searchApiMock.search.mockResolvedValueOnce({
query: "PAP-3366",
normalizedQuery: "pap-3366",
scope: "all",
limit: 20,
offset: 0,
countsByType: { issue: 1, agent: 0, project: 0 },
hasMore: false,
results: [
{
id: "issue-3366",
type: "issue",
score: 1300,
title: "PAP-3366 Continuation summary",
href: "/PAP/issues/PAP-3366#document-continuation-summary",
matchedFields: ["identifier", "document"],
sourceLabel: "Document",
snippet: "Continuation summary excerpt",
snippets: [
{
field: "document",
label: "Continuation summary",
text: "Continuation summary excerpt",
highlights: [],
},
],
issue: {
id: "issue-3366",
identifier: "PAP-3366",
title: "Continuation summary",
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
projectId: null,
updatedAt: new Date().toISOString(),
},
updatedAt: new Date().toISOString(),
},
],
});
const { root } = renderSearch("/search?q=PAP-3366", container);
await waitForAssertion(() => {
expect(navigateMock).toHaveBeenCalledWith("/PAP/issues/PAP-3366", { replace: true });
});
act(() => {
root.unmount();
});
});
it("renders the no-results state with a Search-all action when scope is non-default", async () => {
searchApiMock.search.mockResolvedValueOnce({
query: "ghost",
normalizedQuery: "ghost",
scope: "comments",
limit: 20,
offset: 0,
countsByType: { issue: 0, agent: 0, project: 0 },
hasMore: false,
results: [],
});
const { root } = renderSearch("/search?q=ghost&scope=comments", container);
await waitForAssertion(() => {
expect(container.textContent).toContain("No results for");
expect(container.textContent).toContain("ghost");
expect(container.textContent).toContain("Search all scopes");
});
act(() => {
root.unmount();
});
});
});
+631
View File
@@ -0,0 +1,631 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search as SearchIcon, AlertTriangle, FileQuestion, Plus, X } from "lucide-react";
import {
COMPANY_SEARCH_DEFAULT_LIMIT,
COMPANY_SEARCH_SCOPES,
type CompanySearchResponse,
type CompanySearchResult,
type CompanySearchScope,
} from "@paperclipai/shared";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { useNavigate, useSearchParams } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useDialogActions } from "../context/DialogContext";
import { searchApi } from "../api/search";
import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys";
import { loadRecentSearches, pushRecentSearch } from "../lib/recent-searches";
import { PageTabBar, type PageTabItem } from "../components/PageTabBar";
import { IssueGroupHeader } from "../components/IssueGroupHeader";
import { SearchResultRow } from "../components/search/SearchResultRow";
import type { Agent } from "@paperclipai/shared";
const SEARCH_DEBOUNCE_MS = 250;
const IDENTIFIER_PATTERN = /^[A-Z]+-\d+$/;
const SCOPE_LABELS: Record<CompanySearchScope, string> = {
all: "All",
issues: "Issues",
comments: "Comments",
documents: "Documents",
agents: "Agents",
projects: "Projects",
};
type SubGroupKey = "issues" | "comments" | "documents" | "agents" | "projects";
const SUBGROUP_ORDER: SubGroupKey[] = ["issues", "comments", "documents", "agents", "projects"];
const SUBGROUP_LABELS: Record<SubGroupKey, string> = {
issues: "Issues",
comments: "Comments",
documents: "Documents",
agents: "Agents",
projects: "Projects",
};
function classifyResult(result: CompanySearchResult): SubGroupKey {
if (result.type === "agent") return "agents";
if (result.type === "project") return "projects";
const matched = new Set(result.matchedFields);
if (matched.has("title") || matched.has("identifier") || matched.has("description")) return "issues";
if (matched.has("comment")) return "comments";
if (matched.has("document")) return "documents";
return "issues";
}
function buildSubgroups(results: CompanySearchResult[]): Array<{ key: SubGroupKey; results: CompanySearchResult[] }> {
const buckets = new Map<SubGroupKey, CompanySearchResult[]>();
for (const result of results) {
const key = classifyResult(result);
const list = buckets.get(key) ?? [];
list.push(result);
buckets.set(key, list);
}
return SUBGROUP_ORDER.filter((key) => (buckets.get(key)?.length ?? 0) > 0).map((key) => ({
key,
results: buckets.get(key) ?? [],
}));
}
function isCompanySearchScope(value: string | null): value is CompanySearchScope {
return Boolean(value) && (COMPANY_SEARCH_SCOPES as readonly string[]).includes(value as string);
}
function describeScope(scope: CompanySearchScope) {
if (scope === "all") return "All scopes";
return SCOPE_LABELS[scope];
}
export function buildSearchUrl(href: string, query: string, scope: CompanySearchScope): string {
const url = new URL(href);
if (query.length === 0) {
url.searchParams.delete("q");
} else {
url.searchParams.set("q", query);
}
if (scope === "all") {
url.searchParams.delete("scope");
} else {
url.searchParams.set("scope", scope);
}
return `${url.pathname}${url.search}${url.hash}`;
}
function shapeError(error: unknown): { message: string; status?: number } {
if (!error) return { message: "Unknown error" };
if (error instanceof Error) {
const status = (error as Error & { status?: number }).status;
return { message: error.message, status: typeof status === "number" ? status : undefined };
}
return { message: String(error) };
}
export function Search() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { openNewIssue } = useDialogActions();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const urlQuery = searchParams.get("q") ?? "";
const urlScopeRaw = searchParams.get("scope");
const urlScope: CompanySearchScope = isCompanySearchScope(urlScopeRaw) ? urlScopeRaw : "all";
const [draftQuery, setDraftQuery] = useState(urlQuery);
const [committedQuery, setCommittedQuery] = useState(urlQuery);
const [scope, setScope] = useState<CompanySearchScope>(urlScope);
const inputRef = useRef<HTMLInputElement | null>(null);
const lastUrlSyncRef = useRef<string>("");
const lastIdentifierRedirectRef = useRef<string>("");
const [recentSearches, setRecentSearches] = useState<string[]>([]);
useEffect(() => {
setBreadcrumbs([{ label: "Search" }]);
}, [setBreadcrumbs]);
useEffect(() => {
if (!selectedCompanyId) return;
setRecentSearches(loadRecentSearches(selectedCompanyId));
}, [selectedCompanyId]);
// Pull URL changes back into local state (e.g. browser back/forward).
useEffect(() => {
setDraftQuery(urlQuery);
setCommittedQuery(urlQuery);
}, [urlQuery]);
useEffect(() => {
setScope(urlScope);
}, [urlScope]);
// Debounce the draft query into committedQuery and write to URL via replaceState.
useEffect(() => {
if (draftQuery === committedQuery) return;
const handle = window.setTimeout(() => {
setCommittedQuery(draftQuery);
if (typeof window !== "undefined") {
const next = buildSearchUrl(window.location.href, draftQuery, scope);
if (next !== `${window.location.pathname}${window.location.search}${window.location.hash}` && next !== lastUrlSyncRef.current) {
lastUrlSyncRef.current = next;
window.history.replaceState(window.history.state, "", next);
}
}
}, SEARCH_DEBOUNCE_MS);
return () => window.clearTimeout(handle);
}, [draftQuery, committedQuery, scope]);
const handleScopeChange = useCallback(
(next: string) => {
if (!isCompanySearchScope(next) || next === scope) return;
setScope(next);
if (typeof window !== "undefined") {
const url = buildSearchUrl(window.location.href, committedQuery, next);
window.history.pushState(window.history.state, "", url);
}
},
[committedQuery, scope],
);
const trimmedQuery = committedQuery.trim();
const queryEnabled = !!selectedCompanyId && trimmedQuery.length > 0;
const { data, isFetching, error, refetch } = useQuery<CompanySearchResponse>({
queryKey: queryKeys.companySearch.search(
selectedCompanyId ?? "__no-company__",
trimmedQuery,
scope,
COMPANY_SEARCH_DEFAULT_LIMIT,
0,
),
queryFn: () =>
searchApi.search(selectedCompanyId!, {
q: trimmedQuery,
scope,
limit: COMPANY_SEARCH_DEFAULT_LIMIT,
}),
enabled: queryEnabled,
placeholderData: (previousData) => previousData,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const agentsById = useMemo<ReadonlyMap<string, Pick<Agent, "id" | "name">>>(() => {
const map = new Map<string, Pick<Agent, "id" | "name">>();
for (const agent of agents ?? []) map.set(agent.id, agent);
return map;
}, [agents]);
// Persist recent searches once we have a successful response with a non-empty query.
useEffect(() => {
if (!selectedCompanyId) return;
if (!data || !trimmedQuery) return;
const next = pushRecentSearch(selectedCompanyId, trimmedQuery);
setRecentSearches(next);
}, [data, trimmedQuery, selectedCompanyId]);
// Identifier shortcut: when q matches PAP-123 and the API returns an exact identifier match, redirect to it.
useEffect(() => {
if (!data) return;
const upper = trimmedQuery.toUpperCase();
if (!IDENTIFIER_PATTERN.test(upper)) return;
if (lastIdentifierRedirectRef.current === upper) return;
const exact = data.results.find(
(result) => result.type === "issue" && result.issue?.identifier?.toUpperCase() === upper,
);
if (!exact?.issue) return;
lastIdentifierRedirectRef.current = upper;
// Strip the comment/document deep-link suffix so an exact identifier match
// lands on the issue root, not the top-scored snippet.
const baseHref = exact.href.split("#")[0] ?? exact.href;
const navigateHref = baseHref.startsWith("/") ? baseHref : `/${baseHref}`;
navigate(navigateHref, { replace: true });
}, [data, navigate, trimmedQuery]);
const handleClear = useCallback(() => {
setDraftQuery("");
setCommittedQuery("");
inputRef.current?.focus();
if (typeof window !== "undefined") {
const next = buildSearchUrl(window.location.href, "", scope);
window.history.replaceState(window.history.state, "", next);
}
}, [scope]);
const focusInput = useCallback(() => {
inputRef.current?.focus();
}, []);
// Global "/" focus shortcut.
useEffect(() => {
function handler(event: KeyboardEvent) {
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target as HTMLElement | null;
const tag = target?.tagName?.toLowerCase();
if (target?.isContentEditable || tag === "input" || tag === "textarea") return;
event.preventDefault();
focusInput();
}
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [focusInput]);
const counts = data?.countsByType ?? { issue: 0, agent: 0, project: 0 };
const totalResults = data?.results.length ?? 0;
const tabItems = useMemo<PageTabItem[]>(() => {
function pill(value: number) {
if (!data) return null;
return (
<Badge variant="outline" className="ml-1.5 px-1.5 py-0 text-[10px] tabular-nums font-normal">
{value}
</Badge>
);
}
const issuesTotal = counts.issue ?? 0;
return COMPANY_SEARCH_SCOPES.map((value) => {
let count: number | null = null;
if (value === "all") count = (counts.issue ?? 0) + (counts.agent ?? 0) + (counts.project ?? 0);
else if (value === "issues") count = issuesTotal;
else if (value === "agents") count = counts.agent ?? 0;
else if (value === "projects") count = counts.project ?? 0;
return {
value,
label: (
<span className="flex items-center">
{SCOPE_LABELS[value as CompanySearchScope]}
{count !== null ? pill(count) : null}
</span>
),
} satisfies PageTabItem;
});
}, [counts, data]);
const subgroups = useMemo(() => buildSubgroups(data?.results ?? []), [data?.results]);
const showInitialState = !trimmedQuery;
const isLoading = queryEnabled && isFetching && !data;
const hasResults = !!data && totalResults > 0;
const isEmpty = !!data && !isFetching && totalResults === 0;
const hasError = !!error && !isLoading;
const apiError = hasError ? shapeError(error) : null;
const apiMessage = data?.results === undefined && data ? null : null;
void apiMessage;
function navigateIssuesFallback() {
navigate(`/issues?q=${encodeURIComponent(trimmedQuery)}`);
}
function handleRecentClick(value: string) {
setDraftQuery(value);
setCommittedQuery(value);
if (typeof window !== "undefined") {
const next = buildSearchUrl(window.location.href, value, scope);
window.history.replaceState(window.history.state, "", next);
}
}
function showAllScope() {
if (scope === "all") return;
handleScopeChange("all");
}
return (
<div className="flex h-full min-h-0 flex-col" data-page="search">
<div className="border-b border-border px-4 py-3 sm:px-6">
<h1 className="sr-only">Search</h1>
<div className="relative">
<SearchIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={inputRef}
autoFocus
value={draftQuery}
onChange={(event) => setDraftQuery(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
if (draftQuery.length > 0) {
event.preventDefault();
handleClear();
} else {
event.currentTarget.blur();
}
}
}}
placeholder="Search issues, comments, documents, agents, projects…"
aria-label="Search query"
className="h-10 pl-9 pr-20 text-sm"
/>
{draftQuery.length > 0 ? (
<button
type="button"
onClick={handleClear}
aria-label="Clear search"
className="absolute right-12 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground hover:bg-accent/50"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
<kbd
aria-hidden
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground"
>
K
</kbd>
</div>
</div>
<Tabs value={scope} onValueChange={handleScopeChange} className="flex h-full min-h-0 flex-col">
<div className="border-b border-border px-2 sm:px-4">
<PageTabBar items={tabItems} value={scope} onValueChange={handleScopeChange} align="start" />
</div>
{COMPANY_SEARCH_SCOPES.map((scopeValue) => (
<TabsContent
key={scopeValue}
value={scopeValue}
className="flex h-full min-h-0 flex-col overflow-y-auto"
>
{scopeValue === scope ? (
<SearchTabContent
showInitialState={showInitialState}
isLoading={isLoading}
hasResults={hasResults}
hasError={hasError}
apiError={apiError}
isEmpty={isEmpty}
trimmedQuery={trimmedQuery}
scope={scope}
showAllScope={showAllScope}
navigateIssuesFallback={navigateIssuesFallback}
openNewIssue={() => openNewIssue({ title: trimmedQuery })}
refetch={() => void refetch()}
recentSearches={recentSearches}
onRecentClick={handleRecentClick}
subgroups={subgroups}
totalResults={totalResults}
isFetching={isFetching && !!data}
agentsById={agentsById}
/>
) : null}
</TabsContent>
))}
</Tabs>
</div>
);
}
interface SearchTabContentProps {
showInitialState: boolean;
isLoading: boolean;
hasResults: boolean;
hasError: boolean;
apiError: { message: string; status?: number } | null;
isEmpty: boolean;
trimmedQuery: string;
scope: CompanySearchScope;
showAllScope: () => void;
navigateIssuesFallback: () => void;
openNewIssue: () => void;
refetch: () => void;
recentSearches: string[];
onRecentClick: (query: string) => void;
subgroups: Array<{ key: SubGroupKey; results: CompanySearchResult[] }>;
totalResults: number;
isFetching: boolean;
agentsById: ReadonlyMap<string, Pick<Agent, "id" | "name">>;
}
function SearchTabContent({
showInitialState,
isLoading,
hasResults,
hasError,
apiError,
isEmpty,
trimmedQuery,
scope,
showAllScope,
navigateIssuesFallback,
openNewIssue,
refetch,
recentSearches,
onRecentClick,
subgroups,
totalResults,
isFetching,
agentsById,
}: SearchTabContentProps) {
if (showInitialState) {
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 px-4 py-10 sm:px-6">
<div>
<h2 className="text-lg font-semibold">Type to search company memory.</h2>
<p className="mt-1 text-sm text-muted-foreground">
Issues, comments, plan documents, agents, projects same surface, ranked by relevance.
</p>
</div>
{recentSearches.length > 0 ? (
<div>
<div className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Recent searches
</div>
<ul className="flex flex-col divide-y divide-border rounded-md border border-border">
{recentSearches.map((entry) => (
<li key={entry}>
<button
type="button"
onClick={() => onRecentClick(entry)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-accent/40"
>
<SearchIcon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="flex-1 truncate">{entry}</span>
</button>
</li>
))}
</ul>
</div>
) : null}
<ul className="space-y-1 text-xs text-muted-foreground">
<li>
<span className="font-medium text-foreground">Identifier lookup:</span> type{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">PAP-123</code> to jump straight to an issue.
</li>
<li>
<span className="font-medium text-foreground">Quoted phrases:</span> wrap a phrase in quotes to match the
exact sequence.
</li>
<li>
<span className="font-medium text-foreground">K:</span> reopens the command palette pre-seeded with your
current query.
</li>
</ul>
</div>
);
}
if (hasError) {
const status = apiError?.status;
return (
<div className="mx-auto flex w-full max-w-xl flex-col items-center justify-center gap-3 px-4 py-12 text-center">
<AlertTriangle className="h-10 w-10 text-destructive" aria-hidden />
<div className="text-base font-semibold">Couldnt run that search</div>
<p className="text-sm text-muted-foreground">
{status ? `The server returned ${status}.` : "The request failed."} Your input and filters are still here, so
you can retry or fall back to the Issues filter.
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button onClick={refetch} variant="default" size="sm">
Retry
</Button>
<Button onClick={navigateIssuesFallback} variant="outline" size="sm">
Open Issues filter view
</Button>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex flex-col gap-2 px-2 py-3 sm:px-4">
<div className="px-3 text-xs text-muted-foreground" data-testid="search-loading">
Searching for &ldquo;{trimmedQuery}&rdquo;
</div>
<div className="flex flex-col">
<div className="px-3 py-2">
<Skeleton className="h-3 w-24" />
</div>
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="flex items-start gap-3 px-3 py-2">
<Skeleton className="mt-1 h-4 w-4 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
);
}
if (isEmpty) {
return (
<div className="mx-auto flex w-full max-w-xl flex-col items-center justify-center gap-3 px-4 py-12 text-center">
<FileQuestion className="h-10 w-10 text-muted-foreground" aria-hidden />
<div className="text-base font-semibold">No results for &ldquo;{trimmedQuery}&rdquo;</div>
<p className="text-sm text-muted-foreground">
We couldnt find a match in {describeScope(scope).toLowerCase()}. Try widening the scope or rephrasing your
query.
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
{scope !== "all" ? (
<Button onClick={showAllScope} size="sm" variant="outline">
Search all scopes
</Button>
) : null}
<Button onClick={openNewIssue} size="sm" variant="default">
<Plus className="mr-1.5 h-4 w-4" />
Create issue from this query
</Button>
<Button onClick={navigateIssuesFallback} size="sm" variant="ghost">
Open Issues filter view
</Button>
</div>
<ul className="mt-2 space-y-0.5 text-xs text-muted-foreground">
<li>Try fewer tokens or a single distinctive term.</li>
<li>
Use an identifier shortcut like <code className="rounded bg-muted px-1 py-0.5">PAP-123</code>.
</li>
<li>Wrap multi-word phrases in quotes.</li>
</ul>
</div>
);
}
if (!hasResults) return null;
return (
<div className="flex w-full max-w-[960px] flex-col px-2 sm:px-4" data-testid="search-results">
<div className="flex items-center justify-between py-2 text-[11px] uppercase tracking-wide text-muted-foreground">
<span>
{totalResults === 1 ? "1 result" : `${totalResults} results`} · sorted by relevance
</span>
{isFetching ? <span aria-live="polite" className="normal-case tracking-normal">Updating</span> : null}
</div>
<div className="flex flex-col pb-10">
{scope === "all" ? (
subgroups.map((group, groupIndex) => (
<section
key={group.key}
aria-label={SUBGROUP_LABELS[group.key]}
className={cn("flex flex-col", groupIndex > 0 && "mt-6")}
>
<IssueGroupHeader
label={SUBGROUP_LABELS[group.key]}
trailing={
<span className="text-xs font-normal tabular-nums text-muted-foreground">
{group.results.length}
</span>
}
className="pt-2 pb-1 text-[11px] tracking-wider text-muted-foreground"
/>
<div className="flex flex-col gap-y-1">
{group.results.map((result) => (
<SearchResultRow
key={`${result.type}:${result.id}:${result.href}`}
result={result}
agentsById={agentsById}
/>
))}
</div>
</section>
))
) : (
<div className="flex flex-col gap-y-1">
{subgroups
.flatMap((group) => group.results)
.map((result) => (
<SearchResultRow
key={`${result.type}:${result.id}:${result.href}`}
result={result}
agentsById={agentsById}
/>
))}
</div>
)}
</div>
</div>
);
}
+308
View File
@@ -0,0 +1,308 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProviderVaultsTab, Secrets } from "./Secrets";
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
providers: vi.fn(),
providerHealth: vi.fn(),
providerConfigs: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
create: vi.fn(),
update: vi.fn(),
rotate: vi.fn(),
disable: vi.fn(),
enable: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
usage: vi.fn(),
accessEvents: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
vi.mock("../api/secrets", () => ({
secretsApi: mockSecretsApi,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({
pushToast: mockPushToast,
}),
useToastActions: () => ({
pushToast: mockPushToast,
}),
}));
vi.mock("../context/SidebarContext", () => ({
useSidebar: () => ({
isMobile: false,
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
const providers: SecretProviderDescriptor[] = [
{
id: "local_encrypted",
label: "Local encrypted",
requiresExternalRef: false,
supportsManagedValues: true,
supportsExternalReferences: false,
configured: true,
},
{
id: "aws_secrets_manager",
label: "AWS Secrets Manager",
requiresExternalRef: false,
supportsManagedValues: true,
supportsExternalReferences: true,
configured: true,
},
{
id: "gcp_secret_manager",
label: "GCP Secret Manager",
requiresExternalRef: false,
supportsManagedValues: false,
supportsExternalReferences: true,
configured: false,
},
{
id: "vault",
label: "Vault",
requiresExternalRef: false,
supportsManagedValues: false,
supportsExternalReferences: true,
configured: false,
},
];
const providerConfigs = [
{
id: "vault-local",
provider: "local_encrypted",
displayName: "Local default",
status: "ready",
isDefault: true,
healthStatus: "ready",
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
},
{
id: "vault-aws",
provider: "aws_secrets_manager",
displayName: "AWS production",
status: "ready",
isDefault: false,
healthStatus: null,
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
},
] satisfies Partial<CompanySecretProviderConfig>[];
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("Secrets page layout", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockSecretsApi.list.mockResolvedValue([]);
mockSecretsApi.providers.mockResolvedValue(providers);
mockSecretsApi.providerHealth.mockResolvedValue({
providers: [
{
provider: "local_encrypted",
status: "warn",
message: "Local encrypted provider has a warning.",
warnings: ["Backup reminder"],
},
],
});
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("uses the shared search/filter/tab affordances and keeps vault sections quiet", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.querySelector('input[data-page-search-target="true"][aria-label="Search secrets"]')).not.toBeNull();
expect(container.textContent).toContain("Use secrets by binding them to runtime environment variables.");
expect(container.textContent).toContain("GH_TOKEN");
expect(container.querySelectorAll("select")).toHaveLength(0);
expect(container.textContent).not.toContain("Provider warnings detected");
expect(container.textContent).not.toContain("2/2 active");
await act(async () => {
root.unmount();
});
const vaultRoot = createRoot(container);
await act(async () => {
vaultRoot.render(
<ProviderVaultsTab
providers={providers}
providerConfigs={providerConfigs as CompanySecretProviderConfig[]}
loading={false}
error={null}
onRetry={vi.fn()}
onCreate={vi.fn()}
onEdit={vi.fn()}
onDisable={vi.fn()}
onSetDefault={vi.fn()}
onHealthCheck={vi.fn()}
pendingActionId={null}
/>,
);
});
await flushReact();
expect(container.querySelector('a[href="#provider-vaults-local_encrypted"]')).not.toBeNull();
expect(container.textContent).toContain("AWS production");
expect(container.textContent).not.toContain("Managed writes");
expect(container.textContent).not.toContain("External refs");
await act(async () => {
vaultRoot.unmount();
});
});
it("opens reference details from the secrets table count", async () => {
mockSecretsApi.list.mockResolvedValue([
{
id: "secret-openai",
companyId: "company-1",
key: "openai_api_key",
name: "OPENAI_API_KEY",
provider: "local_encrypted",
status: "active",
managedMode: "paperclip_managed",
externalRef: null,
providerConfigId: null,
providerMetadata: null,
latestVersion: 1,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: "user-1",
referenceCount: 2,
createdAt: new Date("2026-05-06T00:00:00.000Z"),
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
},
]);
mockSecretsApi.usage.mockResolvedValue({
secretId: "secret-openai",
bindings: [
{
id: "binding-agent",
companyId: "company-1",
secretId: "secret-openai",
targetType: "agent",
targetId: "agent-1",
configPath: "env.OPENAI_API_KEY",
versionSelector: "latest",
required: true,
label: null,
target: {
type: "agent",
id: "agent-1",
label: "CodexCoder",
href: "/agents/codexcoder",
status: "idle",
},
createdAt: new Date("2026-05-06T00:00:00.000Z"),
updatedAt: new Date("2026-05-06T00:00:00.000Z"),
},
],
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const referencesButton = container.querySelector(
'button[aria-label="View references for OPENAI_API_KEY"]',
) as HTMLButtonElement | null;
expect(referencesButton?.textContent).toBe("2");
await act(async () => {
referencesButton?.click();
});
await flushReact();
expect(mockSecretsApi.usage).toHaveBeenCalledWith("secret-openai");
expect(document.body.textContent).toContain("Secret references");
expect(document.body.textContent).toContain("CodexCoder");
expect(document.body.textContent).toContain("env.OPENAI_API_KEY");
await act(async () => {
root.unmount();
});
});
});
+129
View File
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import { describe, expect, it } from "vitest";
import type { SecretProviderDescriptor } from "@paperclipai/shared";
import {
getAwsManagedPathPreview,
getCreateProviderBlockReason,
getDefaultProviderConfigId,
getProviderConfigBlockReason,
} from "./Secrets";
import type { SecretProviderHealthResponse } from "../api/secrets";
const awsProvider: SecretProviderDescriptor = {
id: "aws_secrets_manager",
label: "AWS Secrets Manager",
requiresExternalRef: false,
supportsManagedValues: true,
supportsExternalReferences: true,
configured: true,
};
describe("Secrets page provider helpers", () => {
it("previews the derived AWS managed path from provider health details", () => {
const health: SecretProviderHealthResponse = {
providers: [
{
provider: "aws_secrets_manager",
status: "ok",
message: "AWS Secrets Manager provider is configured",
details: {
prefix: "paperclip",
deploymentId: "prod-us-1",
},
},
],
};
expect(
getAwsManagedPathPreview({
provider: awsProvider,
health,
companyId: "company-123",
secretKeySource: "Anthropic API Key",
}),
).toBe("paperclip/prod-us-1/company-123/anthropic-api-key");
});
it("blocks unconfigured providers before create submission", () => {
expect(
getCreateProviderBlockReason(
{ ...awsProvider, configured: false },
"managed",
null,
),
).toBe("AWS Secrets Manager is not configured in this deployment.");
});
it("uses provider health copy when an unconfigured provider reports missing bootstrap inputs", () => {
const health: SecretProviderHealthResponse = {
providers: [
{
provider: "aws_secrets_manager",
status: "warn",
message:
"AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
},
],
};
expect(
getCreateProviderBlockReason(
{ ...awsProvider, configured: false },
"managed",
health,
),
).toBe(
"AWS Secrets Manager is not configured in this deployment. AWS Secrets Manager provider is not ready: missing PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID.",
);
});
it("blocks provider modes the backend does not support", () => {
expect(
getCreateProviderBlockReason(
{
id: "local_encrypted",
label: "Local encrypted (default)",
requiresExternalRef: false,
supportsManagedValues: true,
supportsExternalReferences: false,
configured: true,
},
"external",
null,
),
).toBe("Local encrypted (default) does not support linked external references.");
});
it("chooses the ready default provider vault for a provider", () => {
expect(
getDefaultProviderConfigId(
[
{
id: "draft",
provider: "aws_secrets_manager",
status: "disabled",
isDefault: true,
},
{
id: "prod",
provider: "aws_secrets_manager",
status: "ready",
isDefault: true,
},
] as never,
"aws_secrets_manager",
),
).toBe("prod");
});
it("explains why coming-soon provider vaults cannot be selected", () => {
expect(
getProviderConfigBlockReason({
id: "vault-draft",
provider: "vault",
status: "coming_soon",
} as never),
).toBe("This provider vault is saved as draft metadata only.");
});
});
File diff suppressed because it is too large Load Diff
+403
View File
@@ -0,0 +1,403 @@
import type { ReactNode } from "react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { SystemNotice } from "@/components/SystemNotice";
import { systemNoticeFixtures } from "@/fixtures/systemNoticeFixtures";
import { cn } from "@/lib/utils";
import {
CircleDashed,
FlaskConical,
Layers,
ListChecks,
Sparkles,
} from "lucide-react";
function LabSection({
id,
eyebrow,
title,
description,
accentClassName,
children,
}: {
id?: string;
eyebrow: string;
title: string;
description: string;
accentClassName?: string;
children: ReactNode;
}) {
return (
<section
id={id}
className={cn(
"rounded-[28px] border border-border/70 bg-background/85 p-4 shadow-[0_24px_60px_rgba(15,23,42,0.08)] sm:p-5",
accentClassName,
)}
>
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground">
{eyebrow}
</div>
<h2 className="mt-1 text-xl font-semibold tracking-tight">{title}</h2>
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{description}</p>
</div>
</div>
{children}
</section>
);
}
function FixtureFrame({ caption, children }: { caption: string; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
<CircleDashed className="h-3.5 w-3.5" />
{caption}
</div>
{children}
</div>
);
}
function MockUserBubble({
authorName,
body,
alignEnd,
}: {
authorName: string;
body: string;
alignEnd?: boolean;
}) {
return (
<div className={cn("flex items-start gap-2.5", alignEnd && "justify-end")}>
{!alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
<div className={cn("flex min-w-0 max-w-[85%] flex-col", alignEnd && "items-end")}>
<div
className={cn(
"mb-1 px-1 text-sm font-medium text-foreground",
alignEnd ? "text-right" : "text-left",
)}
>
{authorName}
</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
{alignEnd ? (
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{authorName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
) : null}
</div>
);
}
function MockAgentBubble({ agentName, body }: { agentName: string; body: string }) {
return (
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>{agentName.slice(0, 2).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">{agentName}</div>
<div className="min-w-0 max-w-full rounded-2xl border border-border/70 bg-background px-4 py-2.5 text-sm leading-6 text-foreground">
{body}
</div>
</div>
</div>
);
}
const checklist = [
"One container per system notice — no nested chat bubble",
"Tone communicated by icon + label, never color alone",
"Operational evidence hidden behind Details, expanded only on demand",
"Issue, agent, and run metadata render as typed link rows, not raw markdown",
"Hierarchy visibly distinct from user (right-aligned) and agent (left-aligned) bubbles",
];
export function SystemNoticeUxLab() {
const fixtureById = new Map(systemNoticeFixtures.map((f) => [f.id, f] as const));
const warningCollapsed = fixtureById.get("warning-collapsed")!;
const warningExpanded = fixtureById.get("warning-expanded")!;
const dangerCollapsed = fixtureById.get("danger-collapsed")!;
const dangerExpanded = fixtureById.get("danger-expanded")!;
const neutralCollapsed = fixtureById.get("neutral-collapsed")!;
const neutralExpanded = fixtureById.get("neutral-expanded")!;
const warningNoDetails = fixtureById.get("warning-no-details")!;
return (
<div className="space-y-6">
<div className="overflow-hidden rounded-[32px] border border-border/70 bg-[linear-gradient(135deg,rgba(245,158,11,0.10),transparent_28%),linear-gradient(180deg,rgba(8,145,178,0.08),transparent_44%),var(--background)] shadow-[0_30px_80px_rgba(15,23,42,0.10)]">
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.2fr)_320px]">
<div className="p-6 sm:p-7">
<div className="inline-flex items-center gap-2 rounded-full border border-amber-500/25 bg-amber-500/[0.08] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.24em] text-amber-700 dark:text-amber-300">
<FlaskConical className="h-3.5 w-3.5" />
System Notice Lab
</div>
<h1 className="mt-4 text-3xl font-semibold tracking-tight">
First-class system notice treatment
</h1>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
Replaces the current pattern where a Paperclip-authored warning renders inside a user-style
chat bubble. The notice is one container, system-styled, with hidden-by-default operational
metadata. Tone is conveyed by icon, label, and color together so it stays accessible.
</p>
<div className="mt-5 flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
PAP-3525 plan
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
phase 1 UX
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
tones: warning · danger · neutral
</Badge>
</div>
</div>
<aside className="border-t border-border/60 bg-background/70 p-6 lg:border-l lg:border-t-0">
<div className="mb-4 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground">
<ListChecks className="h-4 w-4 text-amber-700 dark:text-amber-300" />
What this lab proves
</div>
<div className="space-y-3">
{checklist.map((line) => (
<div
key={line}
className="rounded-2xl border border-border/70 bg-background/85 px-4 py-3 text-sm text-muted-foreground"
>
{line}
</div>
))}
</div>
</aside>
</div>
</div>
<LabSection
id="tones"
eyebrow="Tone matrix"
title="Three tones, two states"
description="Each tone pairs a unique icon and tone label so the notice is recognizable without color. Collapsed is the default; the Details affordance reveals operational metadata only when reviewers ask for it."
accentClassName="bg-[linear-gradient(180deg,rgba(245,158,11,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-5">
<FixtureFrame caption={warningCollapsed.caption}>
<SystemNotice {...warningCollapsed} />
</FixtureFrame>
<FixtureFrame caption={warningExpanded.caption}>
<SystemNotice {...warningExpanded} />
</FixtureFrame>
<FixtureFrame caption={dangerCollapsed.caption}>
<SystemNotice {...dangerCollapsed} />
</FixtureFrame>
<FixtureFrame caption={dangerExpanded.caption}>
<SystemNotice {...dangerExpanded} />
</FixtureFrame>
<FixtureFrame caption={neutralCollapsed.caption}>
<SystemNotice {...neutralCollapsed} />
</FixtureFrame>
<FixtureFrame caption={neutralExpanded.caption}>
<SystemNotice {...neutralExpanded} />
</FixtureFrame>
<FixtureFrame caption={warningNoDetails.caption}>
<SystemNotice {...warningNoDetails} />
</FixtureFrame>
</div>
</LabSection>
<LabSection
id="hierarchy"
eyebrow="Hierarchy in thread"
title="Distinct from user and agent comments"
description="Side-by-side with adjacent comment types so reviewers can confirm the system row reads as a system row — full width, no avatar gutter, no chat bubble — while user and agent comments keep their existing rounded bubbles."
accentClassName="bg-[linear-gradient(180deg,rgba(8,145,178,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/70 p-4">
<MockUserBubble
authorName="Riley Board"
body="Why does this issue keep waking back up without a clear next step?"
alignEnd
/>
<MockAgentBubble
agentName="CodexCoder"
body="The previous run completed without picking a disposition. I'll wait for the new system notice to surface so the recovery owner is unambiguous."
/>
<SystemNotice
tone="danger"
label="System alert"
source={{ label: "Paperclip", href: "/PAP/agents" }}
timestamp="2026-05-04T16:48:00.000Z"
body="Paperclip could not resolve this issue's missing disposition automatically. The issue is blocked on a recovery owner."
metadata={[
{
title: "Recovery owner",
rows: [
{
kind: "issue",
label: "Recovery issue",
identifier: "PAP-3440",
href: "/PAP/issues/PAP-3440",
title: "Successful run handoff missing disposition",
},
{
kind: "agent",
label: "Owner",
name: "CTO",
href: "/PAP/agents/cto",
},
],
},
{
title: "Run evidence",
rows: [
{
kind: "run",
label: "Source run",
runId: "9cdba892-c7ca-4d93-8604-4843873b127c",
href: "/PAP/agents/codexcoder/runs/9cdba892-c7ca-4d93-8604-4843873b127c",
status: "succeeded",
},
],
},
]}
/>
<MockUserBubble
authorName="Riley Board"
body="Thanks — assigning the recovery owner now."
alignEnd
/>
</div>
</LabSection>
<div className="grid gap-5 xl:grid-cols-2">
<LabSection
eyebrow="Before"
title="Today's nested treatment"
description="The same content rendered through the existing user-bubble + warning-callout path. Two containers, same gray background as user comments, and the warning icon is forced inside a chat row."
accentClassName="bg-[linear-gradient(180deg,rgba(244,63,94,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<div className="flex items-start gap-2.5">
<Avatar size="sm" className="shrink-0">
<AvatarFallback>YO</AvatarFallback>
</Avatar>
<div className="flex min-w-0 max-w-[85%] flex-col">
<div className="mb-1 px-1 text-sm font-medium text-foreground">You</div>
<div className="min-w-0 max-w-full rounded-2xl bg-muted px-4 py-2.5 text-sm leading-6 text-foreground">
<div className="rounded-md border border-red-500/35 bg-red-500/10 px-3 py-2.5 text-sm text-red-950 dark:text-red-100">
<div className="flex items-start gap-2">
<Sparkles className="mt-1 h-4 w-4 shrink-0 text-red-600 dark:text-red-300" />
<div className="min-w-0">
<p className="m-0 font-semibold">Successful run handoff missing</p>
<ul className="mt-1.5 list-disc space-y-0.5 pl-4 text-[13px] leading-5">
<li>Source issue: PAP-3440</li>
<li>Source run: 9cdba892-c7ca-4d93-8604-4843873b127c</li>
<li>Recovery run: 61fdb79b-8012-4676-ac71-2971830e126a</li>
<li>Status before: in_progress</li>
<li>Normalized cause: Run completed without disposition</li>
<li>Recovery owner: CTO</li>
<li>Suggested action: Reassign to recovery agent</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<p className="px-1 text-xs text-muted-foreground">
Author reads as <span className="font-medium text-foreground">You</span> even though the
author is the Paperclip system. Two containers stack the warning inside a user-style
bubble, and operational evidence is always visible.
</p>
</div>
</LabSection>
<LabSection
eyebrow="After"
title="System notice replacement"
description="One container, system-authored label, hidden details. The chat surface keeps user and agent bubbles unchanged."
accentClassName="bg-[linear-gradient(180deg,rgba(16,185,129,0.05),transparent_28%),var(--background)]"
>
<div className="space-y-3 rounded-2xl border border-border/70 bg-background/70 p-4">
<SystemNotice {...dangerCollapsed} />
<p className="px-1 text-xs text-muted-foreground">
Same content. The visible body is one short system sentence; reviewers expand{" "}
<span className="font-medium text-foreground">Details</span> only when they need run
evidence. Tone is reinforced by the octagon icon and the &quot;System alert&quot; label,
not just red.
</p>
</div>
</LabSection>
</div>
<Card className="gap-4 border-border/70 bg-background/85 py-0">
<CardHeader className="px-5 pt-5 pb-0">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
<Layers className="h-4 w-4 text-amber-700 dark:text-amber-300" />
Implementation notes
</div>
<CardTitle className="text-lg">Handoff to engineering</CardTitle>
<CardDescription>
What the Phase 4 UI implementation should preserve from this design.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-5 pt-0 text-sm text-muted-foreground">
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Component</div>
Use <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">{`<SystemNotice />`}</code>{" "}
from <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">@/components/SystemNotice</code>.
It accepts <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">tone</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">label</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">body</code>,{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">metadata</code>, and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">detailsDefaultOpen</code>.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Routing in IssueChatThread</div>
Comments where{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">authorType === &quot;system&quot;</code>{" "}
or{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation.kind === &quot;system_notice&quot;</code>{" "}
should render as a SystemNotice row at full content width never inside an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">IssueChatUserMessage</code>{" "}
or assistant bubble.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Accessibility</div>
The Details button has{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-expanded</code>{" "}
and{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-controls</code>{" "}
wired to the panel id. The container exposes{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">role=&quot;status&quot;</code>{" "}
and an{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">aria-label</code>{" "}
equal to the visible tone label so screen readers announce tone with text.
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 px-4 py-3">
<div className="mb-1 font-medium text-foreground">Legacy fallback</div>
Existing comments without{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation</code>{" "}
keep rendering through the current{" "}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">SuccessfulRunHandoffCommentCallout</code>{" "}
string-detector. The new contract is opt-in for the system generators in Phase 5.
</div>
</CardContent>
</Card>
</div>
);
}
export default SystemNoticeUxLab;
@@ -0,0 +1,820 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
CompanySecret,
CompanySecretProviderConfig,
RemoteSecretImportCandidate,
RemoteSecretImportPreviewResult,
RemoteSecretImportResult,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "../../api/client";
const mockSecretsApi = vi.hoisted(() => ({
remoteImportPreview: vi.fn(),
remoteImport: vi.fn(),
}));
const mockPushToast = vi.hoisted(() => vi.fn());
vi.mock("../../api/secrets", () => ({
secretsApi: mockSecretsApi,
}));
vi.mock("../../context/ToastContext", () => ({
useToastActions: () => ({
pushToast: mockPushToast,
dismissToast: vi.fn(),
clearToasts: vi.fn(),
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
import { ImportFromVaultDialog } from "./ImportFromVaultDialog";
const awsVault: CompanySecretProviderConfig = {
id: "vault-aws",
companyId: "company-1",
provider: "aws_secrets_manager",
displayName: "AWS production",
status: "ready",
isDefault: true,
config: { region: "us-east-1" },
healthStatus: null,
healthCheckedAt: null,
healthMessage: null,
healthDetails: null,
disabledAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
};
function makeCandidate(
overrides: Partial<RemoteSecretImportCandidate> = {},
): RemoteSecretImportCandidate {
return {
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/foo-AbCdEf",
remoteName: "prod/foo",
name: "prod/foo",
key: "prod-foo",
providerVersionRef: null,
providerMetadata: { name: "prod/foo" },
status: "ready",
importable: true,
conflicts: [],
...overrides,
};
}
function makePreview(
candidates: RemoteSecretImportCandidate[],
nextToken: string | null = null,
): RemoteSecretImportPreviewResult {
return {
providerConfigId: awsVault.id,
provider: "aws_secrets_manager",
nextToken,
candidates,
};
}
async function flush() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function flushDebounce() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 300));
});
}
function makeWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return { queryClient };
}
describe("ImportFromVaultDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("loads candidates and selects rows, persisting through pagination", async () => {
mockSecretsApi.remoteImportPreview
.mockResolvedValueOnce(
makePreview(
[
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC",
remoteName: "prod/stripe",
name: "prod/stripe",
key: "prod-stripe",
}),
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
remoteName: "prod/openai",
name: "prod/openai",
key: "prod-openai",
}),
],
"page-2",
),
)
.mockResolvedValueOnce(
makePreview(
[
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9",
remoteName: "prod/sendgrid",
name: "prod/sendgrid",
key: "prod-sendgrid",
}),
],
null,
),
);
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const tableBody = document.querySelector('[data-testid="vault-table-body"]');
expect(tableBody).not.toBeNull();
expect(document.body.textContent).toContain("prod/stripe");
expect(document.body.textContent).toContain("prod/openai");
// Select stripe via row click
const stripeRow = document.querySelector(
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/stripe-ABC"]',
) as HTMLElement | null;
expect(stripeRow).not.toBeNull();
await act(async () => {
stripeRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("1 selected");
// Load more page
const loadMore = document.querySelector('[data-testid="vault-load-more"]') as HTMLButtonElement | null;
expect(loadMore).not.toBeNull();
await act(async () => {
loadMore!.click();
});
await flush();
await flush();
expect(document.body.textContent).toContain("prod/sendgrid");
// Selection persisted through pagination.
expect(document.body.textContent).toContain("1 selected");
await act(async () => {
root.unmount();
});
});
it("disables checkboxes for already-imported (duplicate) rows and shows a conflict badge for conflicts", async () => {
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
makePreview([
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9",
remoteName: "prod/sendgrid",
name: "prod/sendgrid",
key: "prod-sendgrid",
status: "duplicate",
importable: false,
conflicts: [
{ type: "exact_reference", message: "Already imported", existingSecretId: "secret-sg" },
],
}),
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
remoteName: "prod/openai",
name: "prod/openai",
key: "prod-openai",
status: "conflict",
importable: true,
conflicts: [
{ type: "name", message: "Name already in use" },
],
}),
]),
);
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const duplicateRow = document.querySelector(
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/sendgrid-Q9"]',
);
expect(duplicateRow?.getAttribute("data-row-state")).toBe("duplicate");
const duplicateCheckbox = duplicateRow?.querySelector(
'button[role="checkbox"]',
) as HTMLButtonElement | null;
expect(duplicateCheckbox?.getAttribute("data-disabled")).not.toBeNull();
expect(document.body.textContent).toContain("Conflict");
expect(document.body.textContent).toContain("Name already in use");
await act(async () => {
root.unmount();
});
});
it("blocks import when a review row collides with an existing Paperclip secret", async () => {
const conflictCandidate = makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ",
remoteName: "prod/openai",
name: "OPENAI_API_KEY",
key: "openai_api_key",
status: "conflict",
conflicts: [{ type: "key", message: "Key already in use" }],
});
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
makePreview([conflictCandidate]),
);
const existing: CompanySecret[] = [
{
id: "secret-existing",
companyId: "company-1",
key: "openai_api_key",
name: "OPENAI_API_KEY",
provider: "aws_secrets_manager",
status: "active",
managedMode: "external_reference",
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:other-XYZ",
providerConfigId: awsVault.id,
providerMetadata: null,
latestVersion: 1,
description: null,
lastResolvedAt: null,
lastRotatedAt: null,
deletedAt: null,
createdByAgentId: null,
createdByUserId: null,
createdAt: new Date(),
updatedAt: new Date(),
},
];
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={existing}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
// Select the conflict row
const row = document.querySelector(
'[data-testid="vault-row-arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ"]',
) as HTMLElement | null;
await act(async () => {
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
// Click "Continue → Review" button.
const continueBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.includes("Continue"),
);
expect(continueBtn).toBeTruthy();
await act(async () => {
continueBtn!.click();
});
await flush();
// Review step: error message visible, Import button disabled.
expect(document.body.textContent?.toLowerCase()).toContain("a paperclip secret already uses this");
const importBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.startsWith("Import "),
) as HTMLButtonElement | undefined;
expect(importBtn).toBeTruthy();
expect(importBtn?.disabled).toBe(true);
await act(async () => {
root.unmount();
});
});
it("requires lowercase operator-entered keys during review", async () => {
const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ";
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
makePreview([
makeCandidate({
externalRef,
remoteName: "prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
}),
]),
);
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const row = document.querySelector(
`[data-testid="vault-row-${externalRef}"]`,
) as HTMLElement | null;
await act(async () => {
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const continueBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.includes("Continue"),
);
await act(async () => {
continueBtn!.click();
});
await flush();
const keyInput = document.querySelector(
`[data-testid="review-key-${externalRef}"]`,
) as HTMLInputElement | null;
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
valueSetter?.call(keyInput, "MY_KEY");
keyInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
expect(document.body.textContent).toContain("lowercase letters");
const importBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.startsWith("Import "),
) as HTMLButtonElement | undefined;
expect(importBtn?.disabled).toBe(true);
await act(async () => {
root.unmount();
});
});
it("submits the operator-entered review description", async () => {
const externalRef = "arn:aws:secretsmanager:us-east-1:1:secret:prod/openai-XYZ";
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
makePreview([
makeCandidate({
externalRef,
remoteName: "prod/openai",
name: "OpenAI API key",
key: "openai-api-key",
providerMetadata: {
description: "Raw AWS description should not seed the review field",
},
}),
]),
);
mockSecretsApi.remoteImport.mockResolvedValueOnce({
providerConfigId: awsVault.id,
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 0,
errorCount: 0,
results: [
{
externalRef,
name: "OpenAI API key",
key: "openai-api-key",
status: "imported",
reason: null,
secretId: "secret-openai",
conflicts: [],
},
],
});
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const row = document.querySelector(
`[data-testid="vault-row-${externalRef}"]`,
) as HTMLElement | null;
await act(async () => {
row?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flush();
const continueBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.includes("Continue"),
);
await act(async () => {
continueBtn!.click();
});
await flush();
const descriptionInput = document.querySelector(
`[data-testid="review-description-${externalRef}"]`,
) as HTMLInputElement | null;
expect(descriptionInput?.value).toBe("");
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
valueSetter?.call(descriptionInput, "Operator-entered OpenAI key");
descriptionInput!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flush();
const importBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.startsWith("Import "),
) as HTMLButtonElement | undefined;
await act(async () => {
importBtn!.click();
});
await flush();
await flush();
expect(mockSecretsApi.remoteImport).toHaveBeenCalledWith("company-1", {
providerConfigId: awsVault.id,
secrets: [
expect.objectContaining({
externalRef,
description: "Operator-entered OpenAI key",
providerMetadata: null,
}),
],
});
await act(async () => {
root.unmount();
});
});
it("renders mixed import results (created/skipped/failed) and shows error reason", async () => {
mockSecretsApi.remoteImportPreview.mockResolvedValueOnce(
makePreview([
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA",
remoteName: "alpha",
name: "alpha",
key: "alpha",
}),
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB",
remoteName: "beta",
name: "beta",
key: "beta",
}),
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC",
remoteName: "gamma",
name: "gamma",
key: "gamma",
}),
]),
);
const result: RemoteSecretImportResult = {
providerConfigId: awsVault.id,
provider: "aws_secrets_manager",
importedCount: 1,
skippedCount: 1,
errorCount: 1,
results: [
{
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:a-AAA",
name: "alpha",
key: "alpha",
status: "imported",
reason: null,
secretId: "secret-alpha",
conflicts: [],
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:b-BBB",
name: "beta",
key: "beta",
status: "skipped",
reason: "exact reference already imported",
secretId: null,
conflicts: [
{ type: "exact_reference", message: "exact reference already imported" },
],
},
{
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:c-CCC",
name: "gamma",
key: "gamma",
status: "error",
reason: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
secretId: null,
conflicts: [],
},
],
};
mockSecretsApi.remoteImport.mockResolvedValueOnce(result);
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
// Select all loaded
const headerCheckbox = document.querySelector(
'[data-testid="vault-table-body"]',
)?.parentElement?.querySelector('thead button[role="checkbox"]') as HTMLButtonElement | null;
expect(headerCheckbox).toBeTruthy();
await act(async () => {
headerCheckbox!.click();
});
await flush();
// Continue
const continueBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.includes("Continue"),
);
await act(async () => {
continueBtn!.click();
});
await flush();
// Import
const importBtn = Array.from(document.querySelectorAll("button")).find(
(btn) => btn.textContent?.startsWith("Import "),
) as HTMLButtonElement | undefined;
expect(importBtn).toBeTruthy();
await act(async () => {
importBtn!.click();
});
await flush();
await flush();
expect(mockSecretsApi.remoteImport).toHaveBeenCalledTimes(1);
expect(document.body.textContent).toContain("Import complete");
expect(document.body.textContent).toContain("1 created");
expect(document.body.textContent).toContain("1 skipped");
expect(document.body.textContent).toContain("1 failed");
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
expect(document.body.textContent).not.toContain("AccessDeniedException");
expect(document.body.textContent).not.toContain("123456789012");
await act(async () => {
root.unmount();
});
});
it("shows an empty state when no AWS vault is configured", async () => {
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[]}
existingSecrets={[]}
onManageVaults={vi.fn()}
/>
</QueryClientProvider>,
);
});
await flush();
expect(document.querySelector('[data-testid="select-empty-vaults"]')).not.toBeNull();
expect(mockSecretsApi.remoteImportPreview).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("shows a permission-error banner when AWS denies ListSecrets", async () => {
const error = Object.assign(new Error("AccessDeniedException"), {
name: "ApiError",
status: 403,
body: null,
});
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(error);
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const banner = document.querySelector('[data-testid="preview-error-banner"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("Could not load remote secrets");
await act(async () => {
root.unmount();
});
});
it("renders sanitized preview provider errors without raw AWS exception text", async () => {
const rawProviderMessage =
"AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/prod/Paperclip is not authorized";
mockSecretsApi.remoteImportPreview.mockRejectedValueOnce(
new ApiError(
"AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.",
403,
{ error: "AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", details: { code: "access_denied" } },
),
);
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const banner = document.querySelector('[data-testid="preview-error-banner"]');
expect(banner).not.toBeNull();
expect(banner?.textContent).toContain("AWS denied list access");
expect(banner?.textContent).toContain("missing secretsmanager:ListSecrets");
expect(banner?.textContent).not.toContain(rawProviderMessage);
expect(banner?.textContent).not.toContain("arn:aws");
expect(banner?.textContent).not.toContain("123456789012");
await act(async () => {
root.unmount();
});
});
it("debounces search and uses the new query for the next preview", async () => {
mockSecretsApi.remoteImportPreview
.mockResolvedValueOnce(makePreview([makeCandidate()]))
.mockResolvedValueOnce(makePreview([
makeCandidate({
externalRef: "arn:aws:secretsmanager:us-east-1:1:secret:stripe-XYZ",
remoteName: "stripe",
name: "stripe",
key: "stripe",
}),
]));
const { queryClient } = makeWrapper();
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ImportFromVaultDialog
open
onOpenChange={vi.fn()}
companyId="company-1"
providerConfigs={[awsVault]}
existingSecrets={[]}
/>
</QueryClientProvider>,
);
});
await flush();
await flush();
const search = document.querySelector('[data-testid="vault-search"]') as HTMLInputElement;
expect(search).not.toBeNull();
const valueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
await act(async () => {
search.focus();
valueSetter?.call(search, "stripe");
search.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushDebounce();
await flush();
expect(mockSecretsApi.remoteImportPreview).toHaveBeenCalledTimes(2);
const lastCall = mockSecretsApi.remoteImportPreview.mock.calls.at(-1);
expect(lastCall?.[1]).toMatchObject({ query: "stripe" });
await act(async () => {
root.unmount();
});
});
});
File diff suppressed because it is too large Load Diff