mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +02:00
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies and their work > - The inbox is a per-user attention view, so archiving an item must not alter the underlying issue, assignment, or status > - Agents can help responsible users tidy resolved work only when the action is company-scoped, reversible, policy-controlled, and fully attributable > - The database and authorization foundations landed in #9654 and #9658, but the end-to-end archive routes, audit details, agent workflow guidance, and operator UI still need to ship together > - Separate stacked PRs #9659 and #9661 made the complete behavior harder to review and land as one coherent capability > - This pull request consolidates the remaining server, shared-contract, documentation, skill, and UI work on top of current master > - The benefit is a single reviewable change that lets agents safely archive responsible-user inbox items and lets users control or undo that behavior ## Linked Issues or Issue Description ### Subsystem affected Cross-cutting inbox management across shared contracts, server authorization/routes/services, shipped agent skills, and the board UI. ### Problem or motivation Agents may complete work whose issue remains in the responsible user's Mine inbox. Existing board-user archive behavior does not provide the agent-facing policy endpoints, target resolution, heartbeat-run attribution, typed denials, conservative workflow guidance, or UI needed for safe agent-managed cleanup. ### Proposed solution Allow authorized agents to archive or unarchive responsible-user inbox items under the user's open, allowlist, or disabled policy; preserve actor/agent/run attribution in issue detail and activity records; expose policy controls and agent archive attribution in the UI; and document conservative cleanup rules for agents and PR gardening. ### Alternatives considered - Reuse generic issue mutation permissions: rejected because inbox state belongs to a target user and requires user-scoped authorization. - Automatically archive every completed or closed item: rejected because completion signals can still require human review or a decision. - Keep the backend and UI as separate stacked PRs: superseded by this consolidated PR so the complete user-visible behavior can be reviewed and verified together. ### Related work - Builds on merged foundations #9654 and #9658. - Supersedes the remaining stacked changes in #9659 and #9661. - `ROADMAP.md` has no overlapping inbox archive or inbox authorization initiative. ## What Changed - Added shared inbox-agent policy types and validators plus company-scoped self-service policy routes and OpenAPI coverage. - Enabled agent archive/unarchive mutations with responsible-user targeting, policy enforcement, typed failures, attribution, idempotency, and detailed activity auditing. - Returned agent archive attribution in issue detail and documented reversible inbox cleanup semantics in the implementation spec and Paperclip skill. - Added conservative PR-gardening inbox tidy guidance that keeps GitHub access read-only and avoids archiving work that still needs human action. - Added the Profile settings policy control and Issue Properties attribution/unarchive UI with focused component coverage and narrow-pane handling. ## Verification - `pnpm exec vitest run server/src/__tests__/inbox-archive-routes.test.ts server/src/__tests__/inbox-agent-policy-routes.test.ts server/src/__tests__/authorization-service.test.ts server/src/__tests__/openapi-routes.test.ts ui/src/components/InboxAgentPolicyControl.test.tsx ui/src/components/IssueProperties.test.tsx` — 110 passed. - `pnpm --filter @paperclipai/db exec vitest run src/inbox-archive-agent-policies-migration.test.ts` — 1 passed. - `node --test .agents/skills/pr-gardening/scripts/pr-gardening.test.mjs` — 9 passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — changed files are clean; the repository-wide command currently reports nine unrelated pre-existing `#9627` literals outside this PR's diff. ## Risks - Agent inbox mutations broaden an existing endpoint path, so authorization and target resolution must remain fail-closed; focused route and authorization tests cover allowed and denied paths. - Archive state affects only the responsible user's inbox presentation and remains reversible; it does not mutate issue status, assignment, or visibility. - The UI policy defaults to the existing open behavior, while allowlist and disabled modes can reduce agent access. - This PR intentionally builds on #9654 and #9658 and contains no new migration number or modification to an already-applied migration. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using GPT-5.4, medium reasoning, repository tool use, shell execution, code review, and test execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (`feat/inbox-agent-archive-complete`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
194 lines
7.6 KiB
TypeScript
194 lines
7.6 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Inbox, LoaderCircle, Save } from "lucide-react";
|
|
import type { InboxAgentPolicy, InboxAgentPolicyMode } from "@paperclipai/shared";
|
|
import { agentsApi } from "@/api/agents";
|
|
import { inboxAgentPolicyApi } from "@/api/inbox-agent-policy";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { isAgentTaskTarget } from "@/lib/company-members";
|
|
import { AgentIcon } from "./AgentIconPicker";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Button } from "@/components/ui/button";
|
|
import { RadioCardGroup, type RadioCardOption } from "@/components/ui/radio-card";
|
|
|
|
const MODE_OPTIONS: RadioCardOption[] = [
|
|
{
|
|
value: "open",
|
|
title: "Any of my agents",
|
|
description: "Let any agent you manage archive tasks out of your inbox.",
|
|
},
|
|
{
|
|
value: "allowlist",
|
|
title: "Only chosen agents",
|
|
description: "Restrict inbox tidying to the agents you pick below.",
|
|
},
|
|
{
|
|
value: "disabled",
|
|
title: "Off",
|
|
description: "Agents can never archive tasks from your inbox.",
|
|
},
|
|
];
|
|
|
|
function policyKey(mode: InboxAgentPolicyMode, allowedAgentIds: string[]): string {
|
|
return `${mode}:${[...allowedAgentIds].sort().join(",")}`;
|
|
}
|
|
|
|
interface Draft {
|
|
mode: InboxAgentPolicyMode;
|
|
allowedAgentIds: string[];
|
|
}
|
|
|
|
/**
|
|
* "Let agents tidy my inbox" user-settings control. A single
|
|
* three-state policy — `open` / `allowlist` / `disabled` — round-tripped through
|
|
* the per-user endpoints. When `allowlist` is selected the user picks which
|
|
* of their agents may archive. The one-click Undo/Unarchive affordance and the
|
|
* "Archived by …" attribution live elsewhere (inbox rows / properties pane).
|
|
*/
|
|
export function InboxAgentPolicyControl({ companyId }: { companyId: string | null | undefined }) {
|
|
const queryClient = useQueryClient();
|
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
const lastServerKeyRef = useRef<string | null>(null);
|
|
|
|
const policyQuery = useQuery({
|
|
queryKey: companyId ? queryKeys.inboxAgentPolicy.mine(companyId) : ["inbox-agent-policy", "none"],
|
|
queryFn: () => inboxAgentPolicyApi.getMine(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
const policy = policyQuery.data;
|
|
|
|
const agentsQuery = useQuery({
|
|
queryKey: companyId ? queryKeys.agents.list(companyId) : ["agents", "none"],
|
|
queryFn: () => agentsApi.list(companyId!),
|
|
enabled: !!companyId,
|
|
});
|
|
const selectableAgents = useMemo(
|
|
() => (agentsQuery.data ?? []).filter(isAgentTaskTarget),
|
|
[agentsQuery.data],
|
|
);
|
|
|
|
// Adopt server state on first load, or on refetch when the user has not
|
|
// diverged from the previously-synced snapshot (so a background refetch never
|
|
// clobbers pending edits).
|
|
useEffect(() => {
|
|
if (!policy) return;
|
|
const serverKey = policyKey(policy.mode, policy.allowedAgentIds);
|
|
setDraft((current) => {
|
|
if (current === null || policyKey(current.mode, current.allowedAgentIds) === lastServerKeyRef.current) {
|
|
return { mode: policy.mode, allowedAgentIds: policy.allowedAgentIds };
|
|
}
|
|
return current;
|
|
});
|
|
lastServerKeyRef.current = serverKey;
|
|
}, [policy]);
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (next: Draft) =>
|
|
inboxAgentPolicyApi.updateMine(companyId!, {
|
|
mode: next.mode,
|
|
allowedAgentIds: next.mode === "allowlist" ? next.allowedAgentIds : [],
|
|
}),
|
|
onSuccess: (saved) => {
|
|
queryClient.setQueryData<InboxAgentPolicy>(queryKeys.inboxAgentPolicy.mine(companyId!), saved);
|
|
},
|
|
});
|
|
|
|
const isDirty = Boolean(
|
|
draft && policy && policyKey(draft.mode, draft.allowedAgentIds) !== policyKey(policy.mode, policy.allowedAgentIds),
|
|
);
|
|
|
|
if (policyQuery.error) {
|
|
return (
|
|
<div className="text-sm text-destructive">
|
|
{policyQuery.error instanceof Error ? policyQuery.error.message : "Failed to load inbox agent policy."}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (policyQuery.isLoading || !draft) {
|
|
return <div className="text-sm text-muted-foreground">Loading inbox agent policy…</div>;
|
|
}
|
|
|
|
const toggleAgent = (agentId: string, checked: boolean) => {
|
|
setDraft((current) => {
|
|
if (!current) return current;
|
|
const set = new Set(current.allowedAgentIds);
|
|
if (checked) set.add(agentId);
|
|
else set.delete(agentId);
|
|
return { ...current, allowedAgentIds: [...set] };
|
|
});
|
|
};
|
|
|
|
return (
|
|
<section className="space-y-4" aria-label="Let agents tidy my inbox">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<Inbox className="h-5 w-5 text-muted-foreground" />
|
|
<h2 className="text-base font-semibold">Let agents tidy my inbox</h2>
|
|
</div>
|
|
<p className="max-w-2xl text-sm text-muted-foreground">
|
|
Choose whether the agents you manage may archive tasks out of your inbox on your behalf. You can
|
|
undo any archive, and every agent archive is attributed in the task's properties.
|
|
</p>
|
|
</div>
|
|
|
|
<RadioCardGroup
|
|
ariaLabel="Inbox agent archiving policy"
|
|
value={draft.mode}
|
|
onValueChange={(value) => setDraft((current) => (current ? { ...current, mode: value as InboxAgentPolicyMode } : current))}
|
|
options={MODE_OPTIONS}
|
|
className="max-w-2xl"
|
|
/>
|
|
|
|
{draft.mode === "allowlist" ? (
|
|
<div className="max-w-2xl space-y-2 rounded-md border border-border p-3">
|
|
<div className="text-sm font-medium">Agents allowed to tidy my inbox</div>
|
|
{selectableAgents.length === 0 ? (
|
|
<p className="text-xs text-muted-foreground">You don't manage any agents yet.</p>
|
|
) : (
|
|
<ul className="space-y-1.5">
|
|
{selectableAgents.map((agent) => {
|
|
const checked = draft.allowedAgentIds.includes(agent.id);
|
|
return (
|
|
<li key={agent.id}>
|
|
<label className="flex cursor-pointer items-center gap-2.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent/40">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(next) => toggleAgent(agent.id, next === true)}
|
|
aria-label={`Allow ${agent.name} to tidy my inbox`}
|
|
/>
|
|
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
|
<span className="min-w-0 truncate text-sm">{agent.name}</span>
|
|
<span className="shrink-0 text-xs text-muted-foreground">{agent.role}</span>
|
|
</label>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{updateMutation.error ? (
|
|
<div className="max-w-2xl rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
{updateMutation.error instanceof Error ? updateMutation.error.message : "Failed to save inbox agent policy."}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="flex max-w-2xl items-center justify-end gap-3">
|
|
{updateMutation.isSuccess && !isDirty ? (
|
|
<span className="text-xs text-muted-foreground" role="status">Saved</span>
|
|
) : null}
|
|
<Button
|
|
type="button"
|
|
disabled={!isDirty || updateMutation.isPending}
|
|
onClick={() => draft && updateMutation.mutate(draft)}
|
|
>
|
|
{updateMutation.isPending ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
|
{updateMutation.isPending ? "Saving…" : "Save"}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|