import { Fragment } from "react"; import { ISSUE_THREAD_INTERACTION_KINDS, normalizeIssueThreadInteractionResolverPolicy, type InteractionResolverGovernance, type IssueThreadInteractionCanonicalResolverPolicy, type IssueThreadInteractionKind, type IssueThreadInteractionResolverPolicy, } from "@paperclipai/shared"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { resolverPolicyLabel } from "../lib/interaction-audience"; const INTERACTION_KIND_LABELS: Record = { suggest_tasks: "Suggested tasks", ask_user_questions: "Ask user questions", request_confirmation: "Confirmations", request_checkbox_confirmation: "Checkbox confirmations", request_item_verdicts: "Item verdicts", connection_intent: "Connection requests", }; /** * Sentinel for "no override" — Radix Select disallows empty-string item values. * Under the open-default contract (PAP-17280) an absent override *is* the open * audience, so this sentinel is what a company sees selected until it * deliberately narrows a kind. */ export const GOVERNANCE_UNSET = "default"; export type GovernanceSelectValue = typeof GOVERNANCE_UNSET | IssueThreadInteractionResolverPolicy; export type GovernanceField = "defaultPolicy" | "cap"; /** * Only *narrowing* policies are offered. `anyone` is the product default, so * requesting it as a default override is a no-op, and capping at `anyone` cannot * narrow anything — both collapse into the unset sentinel, which is presented as * the visible default. */ const NARROWING_POLICIES: readonly IssueThreadInteractionCanonicalResolverPolicy[] = [ "not_creator", "human_only", ]; const UNSET_LABELS: Record = { defaultPolicy: "Anyone (default)", cap: "No cap", }; const UNSET_EFFECTS: Record = { defaultPolicy: "New cards are open — the board or any agent can respond, including the one that asked.", cap: "A request keeps whatever audience it asks for.", }; const DEFAULT_POLICY_EFFECTS: Record = { anyone: UNSET_EFFECTS.defaultPolicy, not_creator: "New cards exclude the agent that created them, so the answer comes from someone else.", human_only: "New cards wait for a person on the board. Agents are turned away.", }; const CAP_EFFECTS: Record = { anyone: UNSET_EFFECTS.cap, not_creator: "Even a card that asks for Anyone is narrowed to exclude its creator.", human_only: "Every card of this kind waits for a person, whatever it asked for.", }; function governanceOptions(field: GovernanceField): { value: GovernanceSelectValue; label: string; effect: string; }[] { const effects = field === "cap" ? CAP_EFFECTS : DEFAULT_POLICY_EFFECTS; return [ { value: GOVERNANCE_UNSET, label: UNSET_LABELS[field], effect: UNSET_EFFECTS[field] }, ...NARROWING_POLICIES.map((policy) => ({ value: policy as GovernanceSelectValue, label: resolverPolicyLabel(policy), effect: effects[policy], })), ]; } /** * The label a *closed* trigger must show. Derived from the value rather than * looked up in the option list so an out-of-list value (a raw `anyone`, say) * still renders a complete, truthful label instead of falling back to a lie. */ export function governanceValueLabel(field: GovernanceField, value: GovernanceSelectValue): string { return value === GOVERNANCE_UNSET ? UNSET_LABELS[field] : resolverPolicyLabel(value); } /** * Map a persisted override onto a select value. A stored `anyone` — including * the deprecated `board_or_agents` alias — is the open default, so it shows as * the unset sentinel rather than as a narrowing override. */ export function toGovernanceSelectValue( policy: IssueThreadInteractionResolverPolicy | undefined, ): GovernanceSelectValue { if (!policy) return GOVERNANCE_UNSET; const canonical = normalizeIssueThreadInteractionResolverPolicy(policy); return canonical === "anyone" ? GOVERNANCE_UNSET : canonical; } /** * Apply a single (kind, field) change to a governance map immutably, pruning * empty entries so the persisted object stays sparse (only real overrides). */ export function applyGovernanceChange( current: InteractionResolverGovernance, kind: IssueThreadInteractionKind, field: GovernanceField, value: GovernanceSelectValue, ): InteractionResolverGovernance { const next: InteractionResolverGovernance = { ...current }; const entry = { ...(next[kind] ?? {}) }; if (value === GOVERNANCE_UNSET) { delete entry[field]; } else { entry[field] = value; } if (entry.defaultPolicy === undefined && entry.cap === undefined) { delete next[kind]; } else { next[kind] = entry; } return next; } function GovernanceSelect({ field, value, onChange, disabled, testId, ariaLabel, mobileLabel, }: { field: GovernanceField; value: GovernanceSelectValue; onChange: (value: GovernanceSelectValue) => void; disabled?: boolean; testId?: string; ariaLabel: string; mobileLabel: string; }) { const options = governanceOptions(field); return (
{/* * Below `sm` the governance grid collapses to a single column (see the * grid classes on the panel), detaching each select from its column * header. Surface a mobile-only inline label so the control stays * self-describing for sighted users, and always carry `aria-label` for * screen-reader pairing. WCAG 2.1 SC 1.4.10 (Reflow) — design review R2. */} {mobileLabel}
); } /** * Company-level interaction governance: the per-kind default audience and cap. * * The open default is the headline — interactions are resolvable by anyone in * the company unless a row here narrows them — so the panel only offers * narrowing choices and never presents an unrestricted card as board-required * (PAP-17280, contract in `doc/SPEC-implementation.md` §9.8.1). */ export function InteractionGovernancePanel({ governance, onChange, isPending, errorMessage, }: { governance: InteractionResolverGovernance; onChange: (kind: IssueThreadInteractionKind, field: GovernanceField, value: GovernanceSelectValue) => void; isPending?: boolean; errorMessage?: string | null; }) { return (
Interaction governance

Thread interactions are open by default:{" "} Anyone in the organization — the board or any agent, including the one that asked — can respond. Narrow a kind only when you need to.{" "} Default policy is the audience new cards get when the requester does not ask for one;{" "} Cap narrows every request of that kind and can never widen one. Tool-approval confirmations always stay{" "} Human only.

{/* * Responsive: below `sm` the row collapses to a single column so the * two 170px selects never force horizontal overflow on a ~390px * viewport (WCAG 2.1 SC 1.4.10 Reflow — design review R2). Each kind * then stacks as: label → Default policy → Cap, each full-width with * its own inline label. At `sm`+ it restores the aligned 3-col grid. */}
Kind
Default policy
Cap
{ISSUE_THREAD_INTERACTION_KINDS.map((kind) => { const entry = governance[kind] ?? {}; const kindLabel = INTERACTION_KIND_LABELS[kind]; return (
{kindLabel}
onChange(kind, "defaultPolicy", v)} /> onChange(kind, "cap", v)} />
); })}
{errorMessage ? ( {errorMessage} ) : null}
); }