feat(coder): unified Plan/Ask/Bypass permission picker
Replace the raw per-agent mode dropdown in the BooCoder composer with a curated three-option permission ladder mapped generically onto each provider's native modes: `plan` id -> Plan, default -> Ask, isUnattended -> Bypass (claude bypassPermissions, qwen yolo, opencode full-access). modeId stays the single wire field; the active unified mode is derived from it (no contracts change). Native BooCode gains its own mode set: Ask stages to the pending-changes queue (today's behavior), Bypass auto-applies the queue to disk after the turn (interactive messages path + task dispatcher path), Plan falls back to Ask. The shared apps/server inference engine is left untouched. Also preserve isUnattended on live-probed ACP modes so opencode's bypass mode stays detectable from the wire. Coder 373 tests green; coder + web typecheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bot } from 'lucide-react';
|
||||
import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
@@ -14,8 +14,22 @@ import {
|
||||
import { BottomSheet } from '@/components/BottomSheet';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { formatModelLabel } from '@/lib/model-label';
|
||||
import {
|
||||
availablePermissionModes,
|
||||
permissionForModeId,
|
||||
nativeModeForPermission,
|
||||
type PermissionMode,
|
||||
} from '@/lib/permission-mode';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Permission picker icon — varies with the active mode so the (icon-only) control
|
||||
// is glanceable: Eye = Plan (read-only), Shield = Ask, ShieldAlert = Bypass.
|
||||
function permissionIcon(mode: PermissionMode): React.ReactNode {
|
||||
if (mode === 'plan') return <Eye className="size-3 shrink-0" />;
|
||||
if (mode === 'bypass') return <ShieldAlert className="size-3 shrink-0 text-amber-500" />;
|
||||
return <Shield className="size-3 shrink-0" />;
|
||||
}
|
||||
|
||||
const PREFS_KEY = 'boocode.coder.agent-prefs';
|
||||
|
||||
|
||||
@@ -350,7 +364,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
}
|
||||
|
||||
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
||||
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
|
||||
// Unified permission ladder (Plan / Ask / Bypass) mapped onto this provider's
|
||||
// native modes. `value.modeId` stays the wire field; the active unified mode is
|
||||
// derived from it.
|
||||
const permissionModes = availablePermissionModes(currentEntry?.modes ?? []);
|
||||
const currentPermission = permissionForModeId(value.modeId, currentEntry?.modes ?? []);
|
||||
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) }));
|
||||
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
|
||||
|
||||
@@ -380,15 +398,25 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* Mode (shield) only when the provider actually exposes modes. Native
|
||||
BooCoder has none, so it's hidden rather than shown disabled. */}
|
||||
{modeOptions.length > 0 && (
|
||||
{/* Permission ladder (Plan / Ask / Bypass) — shown when the provider exposes
|
||||
modes. Picks the unified mode; we resolve it to the provider's native
|
||||
modeId. Icon varies with the active mode (Bypass is amber). */}
|
||||
{permissionModes.length > 0 && (
|
||||
<CompactPicker
|
||||
label="Mode"
|
||||
value={value.modeId ?? ''}
|
||||
options={modeOptions}
|
||||
onPick={(modeId) => persist({ ...value, modeId })}
|
||||
icon={<Shield className="size-3 shrink-0" />}
|
||||
label="Permission"
|
||||
value={currentPermission}
|
||||
options={permissionModes}
|
||||
onPick={(perm) =>
|
||||
persist({
|
||||
...value,
|
||||
modeId: nativeModeForPermission(
|
||||
perm as PermissionMode,
|
||||
currentEntry?.modes ?? [],
|
||||
currentEntry?.defaultModeId ?? null,
|
||||
),
|
||||
})
|
||||
}
|
||||
icon={permissionIcon(currentPermission)}
|
||||
iconOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
55
apps/web/src/lib/permission-mode.ts
Normal file
55
apps/web/src/lib/permission-mode.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Unified permission ladder shown in the composer's permission picker. Maps a
|
||||
// curated three-option control (Plan / Ask Permission / Bypass) onto each
|
||||
// provider's native mode vocabulary, derived purely from the snapshot's mode
|
||||
// metadata (`plan` id, the default mode, and the `isUnattended` bypass mode).
|
||||
// `modeId` stays the single wire field sent to the dispatcher — there is no
|
||||
// separate persisted permission field; the active unified mode is derived from
|
||||
// the current `modeId`.
|
||||
import type { ProviderMode } from '@/api/types';
|
||||
|
||||
export type PermissionMode = 'plan' | 'ask' | 'bypass';
|
||||
|
||||
export const PERMISSION_LABELS: Record<PermissionMode, string> = {
|
||||
plan: 'Plan',
|
||||
ask: 'Ask Permission',
|
||||
bypass: 'Bypass',
|
||||
};
|
||||
|
||||
/** The native modeId for a unified permission, or null when the provider has no
|
||||
* modes (e.g. goose). `plan` → the `plan`-id mode; `bypass` → the `isUnattended`
|
||||
* mode; `ask` → the non-unattended default. Falls back to defaultModeId. */
|
||||
export function nativeModeForPermission(
|
||||
mode: PermissionMode,
|
||||
modes: ProviderMode[],
|
||||
defaultModeId: string | null,
|
||||
): string | null {
|
||||
if (modes.length === 0) return null;
|
||||
if (mode === 'plan') return modes.find((m) => m.id === 'plan')?.id ?? defaultModeId;
|
||||
if (mode === 'bypass') return modes.find((m) => m.isUnattended)?.id ?? defaultModeId;
|
||||
return (
|
||||
modes.find((m) => m.id === defaultModeId && !m.isUnattended)?.id ??
|
||||
modes.find((m) => !m.isUnattended && m.id !== 'plan')?.id ??
|
||||
defaultModeId
|
||||
);
|
||||
}
|
||||
|
||||
/** Which unified permission a native modeId corresponds to (for picker state). */
|
||||
export function permissionForModeId(modeId: string | null, modes: ProviderMode[]): PermissionMode {
|
||||
if (!modeId) return 'ask';
|
||||
if (modeId === 'plan') return 'plan';
|
||||
if (modes.find((m) => m.id === modeId)?.isUnattended) return 'bypass';
|
||||
return 'ask';
|
||||
}
|
||||
|
||||
/** The unified permission options a provider supports, in fixed Plan→Ask→Bypass
|
||||
* order. Empty when the provider exposes no modes (no picker shown). */
|
||||
export function availablePermissionModes(
|
||||
modes: ProviderMode[],
|
||||
): Array<{ id: PermissionMode; label: string }> {
|
||||
if (modes.length === 0) return [];
|
||||
const out: Array<{ id: PermissionMode; label: string }> = [];
|
||||
if (modes.some((m) => m.id === 'plan')) out.push({ id: 'plan', label: PERMISSION_LABELS.plan });
|
||||
out.push({ id: 'ask', label: PERMISSION_LABELS.ask });
|
||||
if (modes.some((m) => m.isUnattended)) out.push({ id: 'bypass', label: PERMISSION_LABELS.bypass });
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user