mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
## 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>
307 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|