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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">Couldn’t 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 “{trimmedQuery}”…
|
||||
</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 “{trimmedQuery}”</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We couldn’t 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 "System alert" 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 === "system"</code>{" "}
|
||||
or{" "}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[12px]">presentation.kind === "system_notice"</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="status"</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
Reference in New Issue
Block a user