/** * What Piggy is allowed to do, chosen before the question is asked. * * This is the only control in PIG that decides whether a language model may * write to the company's book, so it is written to be read rather than to be * clever. Four things follow from that and are deliberate: * * names — the segments say "Read only", "Ask first" and "Auto", not * `read_only` / `confirm` / `auto`. The enum is the wire's language * and nobody choosing a permission should have to learn it. * consequence — the sentence under the segments describes the mode that is * selected NOW, and changes as the selection does. A toggle whose * meaning lives in documentation is a toggle people set once and then * misremember. * the keyboard — the arrows move focus and do NOT select. The ARIA pattern * says they should, and for a preference it would be right; for a * permission it meant that arrowing across to read what Auto does * turned Auto on. See `keyboardAt`. * the exceptions — `auto` still stops at a contract, a commitment, an * allocation and anything compliance-shaped. That is `requiresApproval`'s * rule, and if the control does not say so, the first person to choose * auto will reasonably assume nothing stops, and will either be * frightened of the mode or trust it further than it deserves. * * The mode is NOT enforced here. `requiresApproval` in @pig/core is the single * source of truth and the agent applies it server-side; this control only tells * the relay what the user picked. Treating it as a guard would put the * authorisation in the browser, where the user can edit it. */ import { useCallback, useEffect, useId, useRef, useState, type JSX } from 'react'; import { Eye, ListChecks, TriangleAlert, Zap, type LucideIcon } from 'lucide-react'; import { PIGGY_ALWAYS_CONFIRM_KINDS, type PiggyGuardedKind, type PiggyMode, } from '@pig/core'; import { PIGGY_DEFAULT_MODE } from '@/lib/piggy-chat'; import { useOptionalIdentity } from '@/lib/identity'; import { Label, cn } from '@/components/ui'; // ------------------------------------------------------------------- copy interface ModeOption { value: PiggyMode; /** The user's word for it. */ label: string; icon: LucideIcon; /** What choosing this mode means, in one sentence, present tense. */ sentence: string; /** True when picking it hands an agent the ability to write unattended. */ consequential?: boolean; } /** * The four kinds `requiresApproval` refuses to automate, spelled for a person. * * Derived from `PIGGY_ALWAYS_CONFIRM_KINDS` rather than typed out, because the * sentence is a promise about policy: if a fifth guarded kind is added upstream * and this copy were a literal, the control would quietly go on promising four. * The map is exhaustive by type, so adding one there fails the build here. */ const GUARDED_KIND_LABELS: Record = { contract: 'contracts', commitment: 'commitments', allocation: 'allocations', compliance: 'compliance', }; const GUARDED_SENTENCE = (() => { const names = PIGGY_ALWAYS_CONFIRM_KINDS.map((kind) => GUARDED_KIND_LABELS[kind]); // en-GB: "contracts, commitments, allocations and compliance". const list = new Intl.ListFormat('en-GB', { style: 'long', type: 'conjunction' }).format(names); return `${list.charAt(0).toUpperCase()}${list.slice(1)} still stop for your approval.`; })(); const READ_ONLY_OPTION: ModeOption = { value: 'read_only', label: 'Read only', icon: Eye, sentence: 'Piggy answers from your CRM and is offered no tool that could change it.', }; const MODE_OPTIONS: readonly ModeOption[] = [ READ_ONLY_OPTION, { value: 'confirm', label: 'Ask first', icon: ListChecks, sentence: 'Piggy proposes each change and nothing is saved until you press Apply.', }, { value: 'auto', label: 'Auto', icon: Zap, sentence: 'Piggy makes changes to your CRM itself, without asking first.', consequential: true, }, ]; /** Why the write modes are unavailable. Shown, never merely implied. */ const NO_WRITE_REASON = 'Your access does not allow changing records, so Piggy can only read.'; /** * The user's word for a mode, and its icon, for a control that summarises this * one rather than replacing it — the workspace header's trigger. * * Exported rather than restated at the call site: the trigger says what the * segments say, and a second copy of "Ask first" is a second opinion waiting to * disagree with this file the first time the copy is edited. */ export function piggyModeSummary(mode: PiggyMode): { label: string; icon: LucideIcon } { const option = optionFor(mode); return { label: option.label, icon: option.icon }; } function optionFor(mode: PiggyMode): ModeOption { // The union is closed and the array covers it; the fallback exists so a mode // read back from storage on a future build cannot render an empty control. return MODE_OPTIONS.find((option) => option.value === mode) ?? READ_ONLY_OPTION; } // ---------------------------------------------------------------- control export function PiggyModeControl({ value, onChange, compact = false, canWrite, }: { value: PiggyMode; onChange: (mode: PiggyMode) => void; compact?: boolean; canWrite: boolean; }): JSX.Element { const describedBy = useId(); const buttons = useRef(new Map()); /** * Where the keyboard is, which is not the same as what is chosen. * * The ARIA radiogroup pattern normally selects whatever the arrow keys land * on. That is right for a preference and wrong for a permission: arrowing * across to read what Auto does was granting an agent unattended write access * to the book, and the sentence explaining the consequence appeared *because* * the consequence had already been accepted. Here the arrows move focus, the * sentence updates to describe what is under the cursor, and Space or Enter * is what commits. Null means the keyboard is elsewhere and the tabstop * belongs to the selected segment, so tabbing back in returns to the choice * in force rather than to wherever the last arrow press stopped. */ const [keyboardAt, setKeyboardAt] = useState(null); /** * What is drawn as selected. Not necessarily what the parent holds: a stored * `auto` outlives the capability that justified it, so someone whose write * grant was removed would otherwise open the composer being told Piggy is * about to edit records it will now be refused. */ const selected: PiggyMode = canWrite ? value : 'read_only'; useEffect(() => { // The correction is pushed up rather than kept local, because the parent is // what puts `mode` on the wire. Showing read-only while sending `auto` // would be the one disagreement this control must never have. It cannot // loop: the parent's next value satisfies the condition. if (!canWrite && value !== 'read_only') onChange('read_only'); }, [canWrite, value, onChange]); const choices = MODE_OPTIONS.filter((option) => canWrite || option.value === 'read_only'); /** Roving tabstop: the keyboard's position if it has one, else the choice. */ const roving: PiggyMode = keyboardAt ?? selected; const moveTo = useCallback((next: PiggyMode | undefined) => { if (!next) return; setKeyboardAt(next); buttons.current.get(next)?.focus(); }, []); const step = useCallback( (direction: 1 | -1) => { const index = choices.findIndex((option) => option.value === roving); moveTo(choices[(index + direction + choices.length) % choices.length]?.value); }, [choices, moveTo, roving], ); /** * The sentence describes what the keyboard is on, not what is chosen — so * someone arrowing across Auto reads its consequence before deciding, which * is the whole point of no longer selecting on focus. */ const active = optionFor(roving); /** True while the keyboard is reading a mode that has not been chosen. */ const previewing = roving !== selected; return (
{compact ? null : }
{ // Leaving the group hands the tabstop back to the chosen segment, so // the next Tab in lands on the mode in force rather than on whichever // one the reader stopped over last time. if (!event.currentTarget.contains(event.relatedTarget)) setKeyboardAt(null); }} onKeyDown={(event) => { if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { event.preventDefault(); step(1); } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { event.preventDefault(); step(-1); } else if (event.key === 'Home') { event.preventDefault(); moveTo(choices[0]?.value); } else if (event.key === 'End') { event.preventDefault(); moveTo(choices[choices.length - 1]?.value); } }} > {MODE_OPTIONS.map((option) => { const isSelected = option.value === selected; const disabled = !canWrite && option.value !== 'read_only'; const Icon = option.icon; return ( ); })}
{/* Announced on change, because the consequence arrives a beat after the press and a screen-reader user gets no colour to tell them the tone of the panel changed. It follows the keyboard rather than the choice, so arrowing across Auto reads its consequence — which is the only way to find out, now that arrowing no longer turns it on. */}
{active.consequential ? (

{previewing ? {active.label}: : null} {active.sentence} {GUARDED_SENTENCE} {previewing ? ' Press Enter to choose it.' : null}

) : (

{previewing ? {active.label}: : null} {active.sentence} {previewing ? ' Press Enter to choose it.' : null}

)} {canWrite ? null :

{NO_WRITE_REASON}

}
); } // -------------------------------------------------------------- preference /** * Per user, not per browser. * * Two people share a laptop far more often than a CRM's security model likes to * admit, and a single `pig.piggy.mode` key would hand the second one an agent * already licensed to write by the first. The signed-in id is part of the key * for that reason alone. */ const MODE_STORAGE_PREFIX = 'pig.piggy.mode.'; function isMode(value: unknown): value is PiggyMode { return MODE_OPTIONS.some((option) => option.value === value); } function readStoredMode(key: string | null): PiggyMode | null { if (!key) return null; try { const raw = localStorage.getItem(key); // Validated rather than cast: a value written by an older build, or edited // by hand, would otherwise travel to the relay as a mode and collect a 400 // on every turn until someone cleared their storage. return isMode(raw) ? raw : null; } catch { // Private browsing throws on access. The default is the safe one anyway. return null; } } /** * The stored answer to "what may Piggy do", defaulting to `read_only`. * * `PIGGY_DEFAULT_MODE` is imported rather than restated so this cannot become a * second opinion on what "safe" means; it is read-only, which is both the * safest mode and a useful one — Piggy still answers every question it can * answer, and the only thing withheld is the ability to change records, which * is exactly the thing a person should turn on knowingly. Defaulting to * `confirm` would be defensible on the grounds that it never writes unasked, * but it puts write tools in front of the model on first use for someone who * never asked for them, and the relay would then be told so on every turn. */ export function usePiggyMode(): { mode: PiggyMode; setMode: (mode: PiggyMode) => void } { const identity = useOptionalIdentity(); const key = identity ? `${MODE_STORAGE_PREFIX}${identity.id}` : null; const [mode, setModeState] = useState(() => readStoredMode(key) ?? PIGGY_DEFAULT_MODE); useEffect(() => { // Re-read whenever the person changes. Falling back to the default rather // than keeping what is on screen matters here: a new signed-in user with no // stored preference must not inherit the last one's `auto`. setModeState(readStoredMode(key) ?? PIGGY_DEFAULT_MODE); }, [key]); const setMode = useCallback( (next: PiggyMode) => { setModeState(next); if (!key) return; try { localStorage.setItem(key, next); } catch { // Non-fatal: the choice simply does not survive the tab, and the next // one opens read-only, which is the harmless direction to fail in. } }, [key], ); return { mode, setMode }; }