Files
PaperClipAI/ui/src/components/InteractionGovernancePanel.tsx
T
DottaandPaperclip e826188e82 refactor(settings): unify settings and speed up exports (#11789)
## Thinking Path

> - Paperclip is the open source app that people use to manage AI agents
for work.
> - Operators use the settings area to control a company and its
Paperclip instance.
> - The current navigation separates related settings and uses duplicate
instance pages.
> - Company exports also do independent reads in sequence and do extra
work for previews.
> - Hardened workspace commands can differ from their saved command
after loopback binding.
> - This pull request makes these related operator workflows consistent
and faster.
> - The benefit is one clear settings area, faster exports, and stable
runtime command matching.

## Linked Issues or Issue Description

Refs #338

Related: #9834

**What existing behavior does this improve?**

This improves the company settings UI, company export preparation, and
workspace runtime command matching.

**Current behavior**

Company and instance settings use separate navigation and duplicate
pages. Export preparation reads many independent records in sequence.
Preview generation can also build an unused organization image. A
command with a forced loopback bind can fail to match its saved runtime
command.

**Proposed behavior**

Use one settings navigation and put general instance controls on the
company General page. Load independent export data with bounded
concurrency, skip unused preview image work, and load the export page
only when it is needed. Treat the loopback-bound form of a command as
the same runtime command.

**Reason and benefit**

Operators get one clear settings area. Large company exports need fewer
serialized reads. Export previews and initial UI loads do less work.
Hardened runtime services remain linked to their saved command
definitions.

**Breaking changes**

The obsolete instance General URL redirects to the unified settings
page. Access and Heartbeats remain available, and legacy bookmarks keep
their destinations. No API response shape or database schema changes.

## What Changed

- Unified company and instance settings navigation and removed duplicate
instance settings pages.
- Embedded general instance controls in the company General page and
kept access-sensitive navigation behavior.
- Preserved instance Access and Heartbeats controls in the unified
navigation and normalized old bookmarks to those destinations.
- Improved environment and access-state handling when workspace seed
requests overlap.
- Added bounded export reads, a lighter preview path, deferred export
preparation, and lazy export-page loading.
- Matched loopback-bound runtime commands to their saved command
definitions.
- Added focused shared, server, and UI regression tests.

## Verification

- `pnpm exec vitest run <18 changed test files>`: 18 files and 256 tests
passed.
- `pnpm check:token-gates`: passed all four token gates.
- `pnpm -r typecheck`: passed for all workspace projects.
- `pnpm build`: passed for all workspace projects.
- `pnpm test:run`: tests ran without a reported failure, but the runner
did not close after the server handoff tests. The process closed with
status 0 after an interrupt.
- Focused latest-head route tests: 2 files and 4 tests passed.
- GitHub latest-head checks: all completed without failure.
- Greptile: 5/5 with no unresolved review threads.

## Risks

- Medium risk: settings routes and navigation changed across several
operator roles.
- Medium risk: bounded export concurrency increases simultaneous
database reads. The limits stay below the normal pool size.
- Low risk: runtime command matching accepts only the known Tailscale
HTTPS loopback transformation.
- No migrations are included.

> 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 with a GPT-5-family coding model. The runtime does not
expose the exact deployed model ID or context-window size. Reasoning,
tool use, and local code execution were enabled.

## 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)
- [ ] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details

Exception: This task requires the existing execution branch. The harness
does not permit a branch rename.
- [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>
2026-08-20 12:15:16 -05:00

307 lines
12 KiB
TypeScript

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<IssueThreadInteractionKind, string> = {
suggest_tasks: "Suggested tasks",
ask_user_questions: "Ask user questions",
request_confirmation: "Confirmations",
request_checkbox_confirmation: "Checkbox confirmations",
request_item_verdicts: "Item verdicts",
};
/**
* 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<GovernanceField, string> = {
defaultPolicy: "Anyone (default)",
cap: "No cap",
};
const UNSET_EFFECTS: Record<GovernanceField, string> = {
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<IssueThreadInteractionCanonicalResolverPolicy, string> = {
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<IssueThreadInteractionCanonicalResolverPolicy, string> = {
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 (
<div className="min-w-0">
{/*
* 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.
*/}
<span className="mb-1 block text-xs font-medium text-muted-foreground uppercase tracking-wide sm:hidden">
{mobileLabel}
</span>
<Select value={value} onValueChange={(v) => onChange(v as GovernanceSelectValue)} disabled={disabled}>
<SelectTrigger
size="sm"
aria-label={ariaLabel}
// 208px is sized for the longest label the control can hold —
// `Anyone except creator` needs ~150px of text room, and 170px only
// left 122px after padding, gap and chevron, so it clipped even once
// the effect sentence was gone (PAP-17297).
className="w-full min-w-0 text-xs sm:w-(--sz-208px)"
data-testid={testId}
>
{/*
* Explicit children, not the default `<SelectValue />`. Radix portals
* the selected item's *whole* subtree into an empty value node, which
* dragged each option's effect sentence into the closed trigger and
* clipped the selected label (desktop truncated the prose, mobile cut
* `Anyone (default)` mid-label — PAP-17293/PAP-17297). Passing children
* sets `valueNodeHasChildren`, which suppresses that portal, so the
* trigger shows exactly the label and nothing else.
*/}
<SelectValue>{governanceValueLabel(field, value)}</SelectValue>
</SelectTrigger>
{/*
* Cap the option list so the effect sentences wrap instead of stretching
* the popover past a ~390px viewport (WCAG 2.1 SC 1.4.10 Reflow).
*/}
<SelectContent className="max-w-(--sz-280px) sm:max-w-(--sz-360px)">
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
// Keyboard typeahead matches on `textValue` when given; without it
// Radix would match against the effect prose too.
textValue={option.label}
className="text-xs"
>
{/*
* Effect preview lives inside the option so the consequence of a
* narrowing choice is legible at the moment of choosing, not only
* after saving (PAP-17280).
*/}
<span className="flex min-w-0 flex-col gap-0.5">
<span>{option.label}</span>
<span className="text-(length:--text-micro) text-muted-foreground">
{option.effect}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
/**
* 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 (
<div className="space-y-4" data-testid="company-settings-interaction-governance-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Interaction governance
</div>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Thread interactions are open by default:{" "}
<span className="font-medium text-foreground">Anyone</span> in the company — the
board or any agent, including the one that asked — can respond. Narrow a kind
only when you need to.{" "}
<span className="font-medium text-foreground">Default policy</span> is the
audience new cards get when the requester does not ask for one;{" "}
<span className="font-medium text-foreground">Cap</span> narrows every request of
that kind and can never widen one. Tool-approval confirmations always stay{" "}
<span className="font-medium text-foreground">Human only</span>.
</p>
{/*
* 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.
*/}
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-[1fr_auto_auto] sm:items-center sm:gap-x-4 sm:gap-y-2.5">
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
Kind
</div>
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
Default policy
</div>
<div className="hidden text-xs font-medium text-muted-foreground uppercase tracking-wide sm:block">
Cap
</div>
{ISSUE_THREAD_INTERACTION_KINDS.map((kind) => {
const entry = governance[kind] ?? {};
const kindLabel = INTERACTION_KIND_LABELS[kind];
return (
<Fragment key={kind}>
<div className="text-sm font-medium sm:font-normal">{kindLabel}</div>
<GovernanceSelect
field="defaultPolicy"
testId={`governance-${kind}-default`}
ariaLabel={`Default resolver audience for ${kindLabel}`}
mobileLabel="Default policy"
value={toGovernanceSelectValue(entry.defaultPolicy)}
disabled={isPending}
onChange={(v) => onChange(kind, "defaultPolicy", v)}
/>
<GovernanceSelect
field="cap"
testId={`governance-${kind}-cap`}
ariaLabel={`Resolver cap for ${kindLabel}`}
mobileLabel="Cap"
value={toGovernanceSelectValue(entry.cap)}
disabled={isPending}
onChange={(v) => onChange(kind, "cap", v)}
/>
</Fragment>
);
})}
</div>
{errorMessage ? (
<span className="text-xs text-destructive">{errorMessage}</span>
) : null}
</div>
</div>
);
}