diff --git a/.agents/skills/settings-ui-patterns/SKILL.md b/.agents/skills/settings-ui-patterns/SKILL.md index 4567c2f1..cf5a55c2 100644 --- a/.agents/skills/settings-ui-patterns/SKILL.md +++ b/.agents/skills/settings-ui-patterns/SKILL.md @@ -15,54 +15,74 @@ When examples conflict, shared component/theme and localization contracts win. S ## Canonical Direction -- Prefer flat hierarchy built with spacing and typography. -- Avoid unnecessary cards, wrappers, row chrome, and redundant headings. -- Keep controls compact and align related rows consistently. -- Put checkbox/radio state before labels. -- Use subtle, stable selected-state styling without layout shifts. -- Preserve responsive wrapping/stacking and long-text behavior. +Settings are built from the shared primitives in +`packages/ui/src/components/sections/shared/SettingsSection.tsx`, +`SettingsPageLayout.tsx`, and `SettingsInfoHint.tsx`. Never hand-roll page +chrome, section headers, field rows, checkbox rows, or info tooltips with raw +divs — use the primitives, and extend them (in the shared file) when a new +shape is genuinely missing. + +- Flat hierarchy through spacing and typography; no cards, boxed backgrounds, or row chrome. +- Secondary helper text is hidden behind an info icon (`info` prop); the default view stays quiet. +- Controls have one standard size (`h-9` / select `size="settings"`) and capped widths — no full-bleed inputs. +- Layouts respond to the settings pane width via container queries (`@xl:` / `@3xl:`), never viewport `sm:`/`lg:` breakpoints (the pane is much narrower than the viewport inside the dialog). +- Checkbox/radio state comes before labels; selected states are subtle and never shift layout. ## Load References By Task | Task | Required reference | |---|---| -| Page hierarchy, typography, spacing, columns, responsive grids | `references/layout.md` | -| Chips, radios, checkboxes, numeric overrides, inputs, icon actions, pickers | `references/controls.md` | +| Page skeleton, sections, hierarchy, nav placement, spacing, columns, responsiveness | `references/layout.md` | +| Field rows, checkboxes, radios, chips, selects, inputs, numeric steppers, info hints | `references/controls.md` | | Adding/moving controls, pages, availability, anchors, or search entries | `references/search.md` | Load every matching reference before editing. -## Quick Control Selection +## Quick Primitive Selection -| Need | Shared pattern | +| Need | Shared primitive | |---|---| -| Short selectable options | `Button variant="chip" size="xs"` + `aria-pressed` | -| Mutually exclusive mode list | `Radio` rows | -| Boolean | `Checkbox` | -| Numeric value/override | `NumberInput` | -| Text/path | `Input` with shared adjacent actions | -| Icon-only action | `Button size="icon"` + sprite `Icon` + localized `aria-label` | +| Page wrapper (title, description, save status, scrolling, `@container`) | `SettingsPageLayout` | +| Titled block with divider | `SettingsSection` (`divider={false}` for the first one) | +| Label left / control right | `SettingsFieldRow` | +| Label above control (two-column cells, wide controls) | `SettingsStackedField` | +| Boolean | `SettingsCheckboxRow` | +| Mutually exclusive list | `SettingsRadioGroup` + `SettingsRadioOption` | +| Short segmented options | `SettingsChipGroup` | +| Sub-cluster with a quiet L3 title inside a section | `SettingsControlGroup` | +| Two-column area on wide panes | `SettingsTwoColumn` | +| Helper text on demand (hover + tap) | `info` prop or `SettingsInfoHint` | -Do not introduce `ButtonSmall`, direct Remixicon components, hardcoded user-facing strings, or one-off color/button systems. +Do not introduce raw ``-based info icons, direct Remixicon components, hardcoded user-facing strings, or one-off color/button systems. New icons: reference a Remix icon name in code, then run `bun run icons:generate` to add it to the sprite. + +## Description Policy (info hints) + +- Explanatory prose (what a feature does, when it applies) goes behind the info icon via the `info` prop — never as always-visible `description`. +- Stays visible: security/data-loss warnings, destructive consequences, required syntax/placeholder lists the user reads while typing, dynamic status, empty states, validation errors, active-flow wizard instructions. +- Mixed text: keep the warning sentence visible, move the explanation to `info`. + +## Save Feedback + +`SettingsPageLayout showSaveStatus` renders the shared quiet indicator: success is silent, "Saving…" appears only past ~500 ms, failures show "Save failed". Anything persisted through `updateDesktopSettings` reports automatically; page-specific APIs must call `reportSettingsSaveState` from `@/lib/persistence`. Never add per-page save badges or success toasts for ordinary setting writes. ## Settings Search Contract Every stable Settings control addition or move must consider search in the same change: - explicit registry item in `packages/ui/src/lib/settings/search.ts` when searchable; -- matching `data-settings-item` anchor; +- matching `data-settings-item` anchor (primitives accept `settingsItem`); - localized title/description keys; - availability matching actual render conditions; -- state preparation before highlighting conditional targets. +- when a control moves to another page, update the item's `page` too. Dynamic entity rows normally are not indexed. Load `references/search.md` for exact rules. ## Review Checklist -- Hierarchy reads through spacing and typography without unnecessary boxes. -- Shared controls are used with localized visible/accessibility text. -- Desktop alignment degrades cleanly on narrow/mobile layouts. -- Disabled state affects the control, not unrelated labels, unless intentional. -- Long labels and adjacent actions do not overflow. -- Search registry, anchor, localization, and availability agree. +- Built from shared primitives; no ad-hoc page/section/row markup. +- Explanatory text hidden behind `info`; warnings/syntax/status still visible. +- Container-query (`@xl:`/`@3xl:`) responsiveness — no viewport breakpoints in pane content. +- Controls use the standard size and width caps; no stretched full-width inputs. +- Localized visible and accessibility text everywhere. +- Search registry, anchor, page, localization, and availability agree. - Nearby Settings precedent and relevant tests remain consistent. diff --git a/.agents/skills/settings-ui-patterns/references/controls.md b/.agents/skills/settings-ui-patterns/references/controls.md index d73296d9..08b36ada 100644 --- a/.agents/skills/settings-ui-patterns/references/controls.md +++ b/.agents/skills/settings-ui-patterns/references/controls.md @@ -1,83 +1,83 @@ # Settings Controls -Load `theme-system` for button/icon/color contracts and `locale-ui-patterns` for every visible or accessible string. +Load `theme-system` for button/icon/color contracts and `locale-ui-patterns` +for every visible or accessible string. All primitives/constants come from +`packages/ui/src/components/sections/shared/SettingsSection.tsx` (+ +`SettingsInfoHint.tsx`). -## Choosing A Control +## Standard Sizes And Widths -- Short chip-like option set: shared `Button variant="chip" size="xs"` with `aria-pressed`. -- Explicit mutually exclusive list: shared `Radio`. -- Boolean value: shared `Checkbox`, not paired show/hide buttons. -- Numeric value: shared `NumberInput`. -- Text/path value: shared `Input` plus shared actions. +One control size across Settings — `h-8`: -Do not couple unrelated toggles beneath a synthetic heading. +- `SelectTrigger`: `size={SETTINGS_SELECT_SIZE}` ('settings' → h-8, rounded-md, px-3). +- Custom dropdown triggers (ModelSelector / AgentSelector): `SETTINGS_CUSTOM_TRIGGER_CLASS`. +- Text `Input` next to dropdowns: `h-8 rounded-md px-3` (match the trigger footprint). +- Icon action next to a control: `SETTINGS_ICON_BUTTON_CLASS`. -## Segmented Option +Widths are capped — never let controls span the pane: + +- Field-row control cluster / stacked-field default cap: `max-w-[24rem]` (built into `SettingsStackedField`; use `SETTINGS_CONTROL_CLUSTER_CLASS` elsewhere). +- Field-row selects: `SETTINGS_SELECT_ROW_TRIGGER_CLASS` (full width narrow, `@xl:w-56` wide). +- Stacked-field selects: `SETTINGS_SELECT_TRIGGER_CLASS` (fills the capped container). +- Genuinely full-width content (dialog textareas): opt out with `controlClassName="w-full max-w-none"`. + +## Field Rows ```tsx - + + - - + + + ``` -- Prefer compact inputs in dense rows. -- Avoid large select triggers in Settings. -- Use shared `Button` and sprite `Icon`, never wrapper buttons or direct Remixicon imports. +Skip per-option descriptions when labels are self-explanatory. For short +segmented choices use `SettingsChipGroup` (chips with `aria-pressed`). + +## Numeric Value / Override + +`NumberInput` inside `SETTINGS_NUMBER_STEPPER_ROW_CLASS`, with +`SETTINGS_NUMBER_UNIT_CLASS` for the unit and an adjacent +`SETTINGS_ICON_BUTTON_CLASS` reset button. Never flex-grow the stepper. +Optional overrides: empty means "inherit"; provide `fallbackValue`, +`onClear`, `emptyLabel="—"`. + +## Info Hints + +`SettingsInfoHint` is the only info-icon implementation: it opens on hover +AND on click (touch devices have no hover), and closes on outside tap. +Prefer the `info` prop of the enclosing primitive; use the component +directly only next to raw labels/headings. Never build info icons from raw +`` + `` — those don't work on mobile. ## Mobile Constraints @@ -89,3 +89,10 @@ Keep reset adjacent. Prefer an info tooltip over persistent helper text when the - Place icon/color palettes beneath their label. - Keep option dimensions and gaps consistent. - Use stable border/ring/background selection; avoid scale transforms that shift layout. + +## Dialogs + +Dialogs reuse the same primitives (`SettingsCheckboxRow`, +`SETTINGS_FIELD_LABEL_CLASS`, `SettingsStackedField`) and the same sizes. +Dividers between dialog form groups are acceptable; wizard step +instructions guiding an active flow stay visible (not behind info). diff --git a/.agents/skills/settings-ui-patterns/references/layout.md b/.agents/skills/settings-ui-patterns/references/layout.md index e65310ae..36007eac 100644 --- a/.agents/skills/settings-ui-patterns/references/layout.md +++ b/.agents/skills/settings-ui-patterns/references/layout.md @@ -1,53 +1,67 @@ # Settings Layout -## Visual Hierarchy +All primitives and class constants below live in +`packages/ui/src/components/sections/shared/SettingsSection.tsx` and +`SettingsPageLayout.tsx`. Import them; never re-declare local equivalents. -- Prefer spacing and typography over boxed backgrounds. -- Avoid wrappers that mix unrelated controls. -- Omit redundant headings when page context already names the controls. -- Keep controls compact and row chrome minimal. -- Place checkbox/radio state before its label. -- Dim inactive option labels subtly; do not use transform jumps. +## Page Skeleton -## Typography +```tsx + + + + +``` -Use classes from `packages/ui/src/lib/typography.ts`: +- `SettingsPageLayout` owns scrolling, page padding, the `@container` context, and the quiet save indicator (`showSaveStatus`). +- Sections separate with a top border (`divider`, default true); the first section under the page header passes `divider={false}`. +- Section titles are real headers (L2). Do not nest an umbrella section around a list of `SettingsControlGroup`s when each group deserves its own header — promote groups to sections instead (see the Chat page precedent). -- Page title: `typography-ui-header font-semibold text-foreground` -- Section header: `typography-ui-header font-medium text-foreground` -- Control group: `typography-ui-header font-medium` or `font-normal` when needed -- Values/labels: `typography-ui-label text-foreground` -- Helper/meta: `typography-meta text-muted-foreground` or `typography-small text-muted-foreground` -- Numeric values: add `tabular-nums` +## Hierarchy Levels + +| Level | Component / class | Use | +|---|---|---| +| L1 | `SETTINGS_PAGE_TITLE_CLASS` (via `SettingsPageLayout`) | Page title | +| L2 | `SettingsSection` title (`SETTINGS_SECTION_TITLE_CLASS`) | Section | +| L3 | `SettingsControlGroup` title (`SETTINGS_GROUP_TITLE_CLASS`) | Sub-cluster inside a section | +| L4 | `SETTINGS_FIELD_LABEL_CLASS` | Field / control labels | +| Helper | `SETTINGS_HELPER_CLASS`, `SETTINGS_DESCRIPTION_CLASS` | Rare visible helper text (most goes behind `info`) | + +## Navigation Placement + +Sidebar groups (`packages/ui/src/lib/settings/metadata.ts`, order in `SettingsView.tsx`): + +- **OpenChamber** (`general` group): General, Appearance, Chat, Notifications, Sessions, Shortcuts, Voice, Usage, About. +- **Workspace** (`projects`): Projects, Remote Instances, External Tunnel, Git. +- **OpenCode** (`opencode`): Providers, Agents, Behavior, Commands, MCP, Plugins. +- **Library** (`content`): Magic Prompts, Snippets, Skills, Skills Catalog. + +Placement rules: + +- **General** hosts app-level settings that don't belong to a feature page: startup/tray/window, network access + UI password, passkeys, OpenCode CLI binary, terminal shell/navigation, message stream transport, privacy. +- Feature pages (Appearance, Chat, Sessions…) keep only settings about that feature. If a setting reads awkwardly on its page, move it to General rather than inventing a new page. +- New pages need metadata, `pageOrder`, nav icon, `settings.page..title/description` in every locale, and mobile whitelist (`MOBILE_SETTINGS_PAGES` in `MobileApp.tsx`) when relevant. + +## Responsiveness: Container Queries + +The settings pane is far narrower than the viewport (3-pane dialog). All +pane content responds to the pane via container queries — `@xl:` (36rem) and +`@3xl:` (48rem) — never viewport `sm:`/`lg:`. `SettingsPageLayout` provides +the `@container` scope; `SettingsFieldRow`, `SettingsTwoColumn`, and the +trigger-width constants already carry the right variants. + +Exception: `SettingsView` navigation chrome (outside the pane) uses viewport +`sm:` to give phones 44px touch rows and plain `bg-background`; keep that +pattern when touching nav. ## Spacing -- Keep section-to-section spacing larger than header-to-content spacing. -- Typical flat section: header `mb-1 px-1`, content `pt-0 pb-2 px-2`, outer `mb-8`. -- Group related controls with `space-y-3` and modest internal padding such as `p-2`. -- Avoid elevated backgrounds, rounded rows, and hover fills without explicit UX value. - -## Alignment - -For consistent desktop columns: - -```tsx -
- {t(labelKey)} -
...
-
-``` - -- Let narrow layouts stack or wrap. -- Compare the complete control footprint, including adjacent actions, when matching widths. -- Disable only the unavailable control; do not dim the entire label row by default. - -## Responsive Grids - -Use a one-column base and introduce columns at a deliberate breakpoint: - -```tsx -
-``` - -Template fields commonly use `grid grid-cols-1 gap-2 md:grid-cols-2 md:gap-3` with flat `p-2` cells. +- Sections own vertical rhythm: divider + `py-8` come from `SettingsSection`. +- Fields inside a column: `SETTINGS_FIELDS_STACK_CLASS` (`space-y-4`). +- Checkbox/radio lists: `SETTINGS_OPTION_STACK_CLASS` (`space-y-1.5`). +- Two-column areas: `SettingsTwoColumn` (`@3xl:grid-cols-2`); use `SettingsStackedField` inside cells (a `SettingsFieldRow` overflows half-width columns). +- No elevated backgrounds, rounded rows, or hover fills without explicit UX value. diff --git a/.agents/skills/settings-ui-patterns/references/search.md b/.agents/skills/settings-ui-patterns/references/search.md index a3d573c1..13a468ed 100644 --- a/.agents/skills/settings-ui-patterns/references/search.md +++ b/.agents/skills/settings-ui-patterns/references/search.md @@ -5,9 +5,10 @@ Settings search uses an explicit registry; it does not scrape JSX. ## Required Integration - Add/update items in `packages/ui/src/lib/settings/search.ts`. -- Add a matching `data-settings-item="..."` anchor to the rendered setting. +- Add a matching `data-settings-item="..."` anchor to the rendered setting — shared primitives take it via their `settingsItem` prop. - Use localized labels/descriptions from every `packages/ui/src/lib/i18n/messages/*.settings.ts` dictionary. -- For a new top-level page, add metadata in `packages/ui/src/lib/settings/metadata.ts` and searchable content unless the page is purely navigational. +- For a new top-level page, add metadata in `packages/ui/src/lib/settings/metadata.ts` and searchable content unless the page is purely navigational; also extend `pageOrder`/nav icon in `SettingsView.tsx` and `MOBILE_SETTINGS_PAGES` in `MobileApp.tsx` when the page applies to mobile. +- When a control moves between pages (e.g. into General), update the registry item's `page` — item `id`s stay stable even if they carry the old page prefix. ## Registry Rules diff --git a/.agents/skills/theme-system/SKILL.md b/.agents/skills/theme-system/SKILL.md index 71c9dafd..028d3a92 100644 --- a/.agents/skills/theme-system/SKILL.md +++ b/.agents/skills/theme-system/SKILL.md @@ -10,6 +10,7 @@ description: Use when creating or modifying OpenChamber UI components, styling, - Use semantic OpenChamber theme tokens; never hardcode hex colors or generic Tailwind palette colors. - Use shared UI primitives before introducing feature-local controls. - Use the shared `Button`; do not create button wrappers such as `ButtonSmall` or `ButtonLarge`. +- Every dropdown-style value-picker trigger (shows current value, opens a picker) takes its chrome from `dropdownTriggerVariants` in `packages/ui/src/components/ui/dropdown-trigger.ts` (sizes: `sm` dense h-6, `default` forms h-8; native `SelectTrigger` consumes it). Call sites add layout classes only (width/truncation) — never re-declare border/radius/bg/hover. Deliberately chrome-less pickers (chat composer, headers) are the only exception. - Use the sprite-based `Icon`; never import icons directly from `@remixicon/react`. - Apply hover tokens only to interactive elements. - Use status colors only for actual status/feedback. diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index c51d892a..7bf5a5e5 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -73,6 +73,7 @@ import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch'; import { useNativePushRegistration } from './useNativePushRegistration'; const MOBILE_SETTINGS_PAGES = [ + 'general', 'appearance', 'chat', 'notifications', diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 05f11bbc..291c3843 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -131,6 +131,7 @@ export const iconSpriteData = { "heart": ``, "history": ``, "home": ``, + "home-office": ``, "hourglass-fill": ``, "hourglass": ``, "image-download": ``, diff --git a/packages/ui/src/components/multirun/AgentSelector.tsx b/packages/ui/src/components/multirun/AgentSelector.tsx index 50b8c0c7..f0899811 100644 --- a/packages/ui/src/components/multirun/AgentSelector.tsx +++ b/packages/ui/src/components/multirun/AgentSelector.tsx @@ -92,10 +92,7 @@ export const AgentSelector: React.FC = ({ diff --git a/packages/ui/src/components/multirun/BranchSelector.tsx b/packages/ui/src/components/multirun/BranchSelector.tsx index 946e5432..b2ba3c86 100644 --- a/packages/ui/src/components/multirun/BranchSelector.tsx +++ b/packages/ui/src/components/multirun/BranchSelector.tsx @@ -9,6 +9,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { cn } from '@/lib/utils'; import { useGitStore, useGitBranches, useGitLoadingBranches, useGitLoadingStatus, useIsGitRepo } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { getRootBranch } from '@/lib/worktrees/worktreeStatus'; @@ -171,7 +172,7 @@ export const BranchSelector: React.FC = ({ diff --git a/packages/ui/src/components/multirun/ModelMultiSelect.tsx b/packages/ui/src/components/multirun/ModelMultiSelect.tsx index d58543a9..dcc62dfb 100644 --- a/packages/ui/src/components/multirun/ModelMultiSelect.tsx +++ b/packages/ui/src/components/multirun/ModelMultiSelect.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Button } from '@/components/ui/button'; +import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { Icon } from "@/components/icon/Icon"; @@ -229,14 +229,13 @@ export const ModelMultiSelect: React.FC = ({
{/* Add model button (dropdown trigger) */}
- + {isOpen ? (
= ({ }} > : undefined} @@ -220,7 +220,7 @@ export function MultiRunFusionDialog({ {variantKeys.length > 0 ? ( ) : null} - +
diff --git a/packages/ui/src/components/sections/agents/AgentPermissionsEditor.test.ts b/packages/ui/src/components/sections/agents/AgentPermissionsEditor.test.ts new file mode 100644 index 00000000..94cd3bec --- /dev/null +++ b/packages/ui/src/components/sections/agents/AgentPermissionsEditor.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from 'bun:test'; + +import { parsePermissionConfig, serializePermissionModel } from './agentPermissionModel'; + +const roundTrip = (config: unknown) => serializePermissionModel(parsePermissionConfig(config)); + +describe('agent permission source round-trip', () => { + test('null and empty configs stay unset', () => { + expect(roundTrip(null)).toBeNull(); + expect(roundTrip(undefined)).toBeNull(); + expect(roundTrip({})).toBeNull(); + }); + + test('bare action string becomes the * key', () => { + expect(roundTrip('ask')).toEqual({ '*': 'ask' }); + }); + + test('flat per-key actions survive verbatim', () => { + const config = { '*': 'allow', bash: 'ask', edit: 'deny' }; + expect(roundTrip(config)).toEqual(config); + }); + + test('nested pattern maps survive verbatim, including wildcard inside', () => { + const config = { + '*': 'ask', + bash: { '*': 'ask', 'rm -rf *': 'deny', 'git status': 'allow' }, + external_directory: { '/tmp/**': 'allow' }, + }; + expect(roundTrip(config)).toEqual(config); + }); + + test('pattern-only key without wildcard stays pattern-only (no synthesized default)', () => { + const config = { read: { '/secret/**': 'deny' } }; + expect(roundTrip(config)).toEqual(config); + }); + + test('explicit allow is preserved — not conflated with unset', () => { + expect(roundTrip({ bash: 'allow' })).toEqual({ bash: 'allow' }); + expect(roundTrip({ '*': 'allow' })).toEqual({ '*': 'allow' }); + }); + + test('unknown junk values are dropped, valid siblings kept', () => { + expect(roundTrip({ bash: 'ask', broken: 42, worse: ['deny'] })).toEqual({ bash: 'ask' }); + }); + + test('clearing everything serializes to null (key removed from config)', () => { + const model = parsePermissionConfig({ bash: 'ask' }); + model.keys = {}; + model.global = null; + expect(serializePermissionModel(model)).toBeNull(); + }); + + test('blank patterns are not persisted', () => { + const model = parsePermissionConfig({}); + model.keys.bash = { action: 'ask', patterns: [{ pattern: ' ', action: 'deny' }] }; + expect(serializePermissionModel(model)).toEqual({ bash: 'ask' }); + }); +}); diff --git a/packages/ui/src/components/sections/agents/AgentPermissionsEditor.tsx b/packages/ui/src/components/sections/agents/AgentPermissionsEditor.tsx new file mode 100644 index 00000000..2638225a --- /dev/null +++ b/packages/ui/src/components/sections/agents/AgentPermissionsEditor.tsx @@ -0,0 +1,504 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Icon } from '@/components/icon/Icon'; +import { toast } from '@/components/ui'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { opencodeClient } from '@/lib/opencode/client'; +import { + useAgentsStore, + getConfigDirectory, + type AgentWithExtras, +} from '@/stores/useAgentsStore'; +import { + SettingsSection, + SettingsChipGroup, + SETTINGS_FIELD_LABEL_CLASS, + SETTINGS_HELPER_CLASS, +} from '@/components/sections/shared/SettingsSection'; +import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint'; +import { + ACTIONS, + cloneModel, + emptyModel, + isAction, + modelsEqual, + parsePermissionConfig, + serializePermissionModel, + type Action, + type EffectiveRule, + type PermissionModel, +} from './agentPermissionModel'; + +/** + * Source-of-truth permissions editor. + * + * This component edits EXACTLY the agent's own `permission` map as stored in + * its markdown frontmatter / opencode.json entry — never the resolved rules + * that `/agent` returns (those already include global config and one-off + * session grants, and writing them back is what used to corrupt configs). + * + * - "Inherit" means the key is absent from the agent's config; the effective + * action (from the resolved view) is shown as a hint. + * - Saving PATCHes only `{ permission }`, and the server writes it verbatim. + */ + + +/** + * Permission keys that exist beyond plain tool ids (virtual capabilities). + * Shown so they are discoverable; nothing is written unless set explicitly. + */ +const VIRTUAL_PERMISSION_KEYS = [ + 'edit', + 'external_directory', + 'doom_loop', + 'plan_enter', + 'plan_exit', +] as const; + +/** + * Keys where opencode matches pattern rules (per docs: these accept either a + * bare action or a pattern map). Everything else is action-only — the pattern + * UI is hidden unless the config already contains patterns for the key. + */ +const PATTERN_CAPABLE_KEYS = new Set([ + 'read', + 'edit', + 'glob', + 'grep', + 'list', + 'bash', + 'task', + 'external_directory', + 'lsp', + 'skill', +]); + +/** Tool ids folded into broader permission keys — never shown standalone. */ +const FOLDED_TOOL_IDS = new Set(['write', 'patch', 'apply_patch', 'multiedit', 'invalid']); + +const formatKeyLabel = (key: string): string => + key + .split(/[_-]/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); + +interface AgentPermissionsEditorProps { + agent: AgentWithExtras; +} + +export const AgentPermissionsEditor: React.FC = ({ agent }) => { + const { t } = useI18n(); + const updateAgent = useAgentsStore((state) => state.updateAgent); + + const [baseline, setBaseline] = React.useState(emptyModel); + const [model, setModel] = React.useState(emptyModel); + const [isLoading, setIsLoading] = React.useState(true); + const [loadFailed, setLoadFailed] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [expandedKeys, setExpandedKeys] = React.useState>({}); + const [toolIds, setToolIds] = React.useState([]); + const [customKeyDraft, setCustomKeyDraft] = React.useState(''); + const [reloadToken, setReloadToken] = React.useState(0); + + const agentName = agent.name; + + // --- Load the SOURCE permission map (the agent's own config file). --- + React.useEffect(() => { + let cancelled = false; + setIsLoading(true); + setLoadFailed(false); + void (async () => { + try { + const directory = getConfigDirectory(); + const query = directory ? `?directory=${encodeURIComponent(directory)}` : ''; + const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, { + headers: { + 'Cache-Control': 'no-cache', + ...(directory ? { 'x-opencode-directory': directory } : {}), + }, + }); + if (!response.ok) throw new Error(String(response.status)); + const data = (await response.json().catch(() => null)) as { config?: { permission?: unknown } } | null; + if (cancelled) return; + const parsed = parsePermissionConfig(data?.config?.permission); + setBaseline(cloneModel(parsed)); + setModel(parsed); + } catch { + if (!cancelled) setLoadFailed(true); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [agentName, reloadToken]); + + // --- Known tool ids for the key list (display only). --- + React.useEffect(() => { + let cancelled = false; + void (async () => { + try { + const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() }); + if (!cancelled && Array.isArray(ids)) { + setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id))); + } + } catch { + // tool ids are additive display data — the editor works without them + } + })(); + return () => { + cancelled = true; + }; + }, [agentName]); + + // --- Effective rules from the resolved view (read-only hints). --- + const effectiveRules = React.useMemo(() => { + const raw = (agent as { permission?: unknown }).permission; + if (!Array.isArray(raw)) return []; + const rules: EffectiveRule[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const { permission, pattern, action } = entry as Record; + if (typeof permission === 'string' && typeof pattern === 'string' && isAction(action)) { + rules.push({ permission, pattern, action }); + } + } + return rules; + }, [agent]); + + const effectiveFor = React.useCallback((key: string): Action | null => { + const exact = effectiveRules.find((rule) => rule.permission === key && rule.pattern === '*'); + if (exact) return exact.action; + const wildcard = effectiveRules.find((rule) => rule.permission === '*' && rule.pattern === '*'); + return wildcard ? wildcard.action : null; + }, [effectiveRules]); + + /** Session/runtime-granted rules that are NOT part of the saved config. */ + const runtimeRulesFor = React.useCallback((key: string): EffectiveRule[] => { + const saved = model.keys[key]?.patterns ?? []; + const savedPatterns = new Set(saved.map((rule) => rule.pattern)); + return effectiveRules.filter( + (rule) => rule.permission === key && rule.pattern !== '*' && !savedPatterns.has(rule.pattern), + ); + }, [effectiveRules, model.keys]); + + // --- Displayed key list: tools + virtual keys + anything set in the config. --- + const displayKeys = React.useMemo(() => { + const keys = new Set(); + for (const id of toolIds) keys.add(id); + for (const key of VIRTUAL_PERMISSION_KEYS) keys.add(key); + for (const key of Object.keys(model.keys)) keys.add(key); + // `edit` covers write/edit/apply_patch — the folded ids never show. + for (const folded of FOLDED_TOOL_IDS) keys.delete(folded); + return Array.from(keys).sort((a, b) => a.localeCompare(b)); + }, [toolIds, model.keys]); + + const isDirty = React.useMemo(() => !modelsEqual(model, baseline), [model, baseline]); + + // --- Mutators --- + const setGlobal = (action: Action | null) => { + setModel((current) => ({ ...current, global: action })); + }; + + const setKeyAction = (key: string, action: Action | null) => { + setModel((current) => { + const next = cloneModel(current); + const state = next.keys[key] ?? { action: null, patterns: [] }; + state.action = action; + if (state.action === null && state.patterns.length === 0) { + delete next.keys[key]; + } else { + next.keys[key] = state; + } + return next; + }); + }; + + const setPattern = (key: string, index: number, pattern: string, action: Action) => { + setModel((current) => { + const next = cloneModel(current); + const state = next.keys[key] ?? { action: null, patterns: [] }; + state.patterns[index] = { pattern, action }; + next.keys[key] = state; + return next; + }); + }; + + const addPattern = (key: string) => { + setModel((current) => { + const next = cloneModel(current); + const state = next.keys[key] ?? { action: null, patterns: [] }; + state.patterns.push({ pattern: '', action: 'allow' }); + next.keys[key] = state; + return next; + }); + setExpandedKeys((current) => ({ ...current, [key]: true })); + }; + + const removePattern = (key: string, index: number) => { + setModel((current) => { + const next = cloneModel(current); + const state = next.keys[key]; + if (!state) return current; + state.patterns.splice(index, 1); + if (state.action === null && state.patterns.length === 0) { + delete next.keys[key]; + } + return next; + }); + }; + + const addCustomKey = () => { + const key = customKeyDraft.trim(); + if (!key || key === '*') return; + setModel((current) => { + if (current.keys[key]) return current; + const next = cloneModel(current); + next.keys[key] = { action: 'ask', patterns: [] }; + return next; + }); + setExpandedKeys((current) => ({ ...current, [key]: true })); + setCustomKeyDraft(''); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const permission = serializePermissionModel(model); + const result = await updateAgent(agentName, { permission }); + if (result.ok) { + setBaseline(cloneModel(model)); + toast.success( + result.requiresManualRestart + ? t('settings.agents.page.permissionsEditor.toast.savedRestartRequired') + : t('settings.agents.page.permissionsEditor.toast.saved'), + ); + } else { + toast.error(t('settings.agents.page.permissionsEditor.toast.saveFailed')); + } + } finally { + setIsSaving(false); + } + }; + + const handleDiscard = () => { + setModel(cloneModel(baseline)); + }; + + const actionLabel = (action: Action): string => t( + action === 'allow' + ? 'settings.agents.page.permissionsEditor.action.allow' + : action === 'ask' + ? 'settings.agents.page.permissionsEditor.action.ask' + : 'settings.agents.page.permissionsEditor.action.deny', + ); + + const inheritLabel = t('settings.agents.page.permissionsEditor.action.inherit'); + const defaultChipLabel = t('settings.agents.page.permissionsEditor.action.default'); + + const chipOptions = (unsetLabel?: string) => [ + ...(unsetLabel ? [{ value: 'inherit', label: unsetLabel }] : []), + ...ACTIONS.map((action) => ({ value: action, label: actionLabel(action) })), + ]; + + const renderActionChips = ( + value: Action | null, + onChange: (action: Action | null) => void, + ariaLabel: string, + unsetLabel: string = inheritLabel, + ) => ( + onChange(next === 'inherit' ? null : (next as Action))} + aria-label={ariaLabel} + /> + ); + + if (isLoading) { + return ( + +

{t('common.loading')}

+
+ ); + } + + if (loadFailed) { + return ( + +
+

+ {t('settings.agents.page.permissionsEditor.state.loadFailed')} +

+ +
+
+ ); + } + + return ( + + + +
+ ) : undefined} + contentClassName="space-y-4" + > + {/* Agent default (the `*` key) */} +
+
+ + {t('settings.agents.page.permissionsEditor.defaultLabel')} + + {t('settings.agents.page.permissionsEditor.defaultInfo')} + {model.global === null && ( + + {t('settings.agents.page.permissionsEditor.effectiveHint', { action: actionLabel(effectiveFor('*') ?? 'allow') })} + + )} +
+ {renderActionChips(model.global, setGlobal, t('settings.agents.page.permissionsEditor.defaultAria'), defaultChipLabel)} +
+ +
+ {displayKeys.map((key) => { + const state = model.keys[key] ?? { action: null, patterns: [] }; + const runtimeRules = runtimeRulesFor(key); + const supportsPatterns = PATTERN_CAPABLE_KEYS.has(key) || state.patterns.length > 0; + const hasDetails = supportsPatterns || runtimeRules.length > 0; + const isExpanded = expandedKeys[key] === true; + const effective = effectiveFor(key); + + return ( +
+
+ + {renderActionChips( + state.action, + (action) => setKeyAction(key, action), + t('settings.agents.page.permissionsEditor.keyAria', { key }), + )} +
+ + {isExpanded && hasDetails && ( +
+ {state.patterns.map((rule, index) => ( +
+ setPattern(key, index, event.target.value, rule.action)} + placeholder={t('settings.agents.page.permissionsEditor.patternPlaceholder')} + className="h-8 w-full max-w-[24rem] min-w-0 flex-1 font-mono text-xs" + /> + setPattern(key, index, rule.pattern, next as Action)} + aria-label={t('settings.agents.page.permissionsEditor.patternActionAria', { key })} + /> + +
+ ))} + + {supportsPatterns && ( + + )} + + {runtimeRules.length > 0 && ( +
+
+ + {t('settings.agents.page.permissionsEditor.sessionRulesTitle')} + + + {t('settings.agents.page.permissionsEditor.sessionRulesInfo')} + +
+ {runtimeRules.map((rule) => ( +
+ + {rule.pattern} + + {actionLabel(rule.action)} +
+ ))} +
+ )} +
+ )} +
+ ); + })} +
+ + {/* Custom permission key */} +
+ setCustomKeyDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + addCustomKey(); + } + }} + placeholder={t('settings.agents.page.permissionsEditor.customKeyPlaceholder')} + className="h-8 w-full max-w-[16rem] font-mono text-xs" + /> + +
+ + ); +}; diff --git a/packages/ui/src/components/sections/agents/AgentsPage.tsx b/packages/ui/src/components/sections/agents/AgentsPage.tsx index 01c30f4b..f8cb2b09 100644 --- a/packages/ui/src/components/sections/agents/AgentsPage.tsx +++ b/packages/ui/src/components/sections/agents/AgentsPage.tsx @@ -6,17 +6,21 @@ import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; import { useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore'; import { useShallow } from 'zustand/react/shallow'; -import { useDirectorySync } from '@/sync/sync-context'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useDeviceInfo } from '@/lib/device'; -import { opencodeClient } from '@/lib/opencode/client'; -import { cn } from '@/lib/utils'; import { ModelSelector } from './ModelSelector'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useI18n } from '@/lib/i18n'; import { parseModelIdentifier } from '@/lib/modelIdentifier'; import { useConfigStore } from '@/stores/useConfigStore'; +import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout'; +import { + SettingsSection, + SettingsFieldRow, + SettingsStackedField, + SettingsChipGroup, + SETTINGS_SELECT_SIZE, + SETTINGS_SELECT_ROW_TRIGGER_CLASS, + SETTINGS_ICON_BUTTON_CLASS, + SETTINGS_CUSTOM_TRIGGER_CLASS, +} from '@/components/sections/shared/SettingsSection'; import { Select, SelectContent, @@ -25,173 +29,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Icon } from '@/components/icon/Icon'; - -type PermissionAction = 'allow' | 'ask' | 'deny'; -type PermissionRule = { permission: string; pattern: string; action: PermissionAction }; -type PermissionConfigValue = PermissionAction | Record; -type PermissionRuleKey = `${string}::${string}`; - -const STANDARD_PERMISSION_KEYS = [ - '*', - 'read', - 'edit', - 'glob', - 'grep', - 'list', - 'bash', - 'task', - 'skill', - 'lsp', - 'todoread', - 'todowrite', - 'webfetch', - 'websearch', - 'codesearch', - 'external_directory', - 'doom_loop', - 'question', - 'plan_enter', - 'plan_exit', -] as const; - -const isPermissionAction = (value: unknown): value is PermissionAction => - value === 'allow' || value === 'ask' || value === 'deny'; - -const buildRuleKey = (permission: string, pattern: string): PermissionRuleKey => - `${permission}::${pattern}`; - -const normalizeRuleset = (ruleset: PermissionRule[]): PermissionRule[] => { - const map = new Map(); - for (const rule of ruleset) { - if (!rule.permission || rule.permission === 'invalid') { - continue; - } - if (!rule.pattern) { - continue; - } - if (!isPermissionAction(rule.action)) { - continue; - } - map.set(buildRuleKey(rule.permission, rule.pattern), { - permission: rule.permission, - pattern: rule.pattern, - action: rule.action, - }); - } - return Array.from(map.values()); -}; - -const buildRuleMap = (ruleset: PermissionRule[]): Map => { - const map = new Map(); - for (const rule of normalizeRuleset(ruleset)) { - map.set(buildRuleKey(rule.permission, rule.pattern), rule); - } - return map; -}; - -const sortRules = (ruleset: PermissionRule[]): PermissionRule[] => - [...ruleset].sort((a, b) => { - const permissionCompare = a.permission.localeCompare(b.permission); - if (permissionCompare !== 0) return permissionCompare; - return a.pattern.localeCompare(b.pattern); - }); - -const areRulesEqual = (a: PermissionRule[], b: PermissionRule[]): boolean => { - const sortedA = sortRules(normalizeRuleset(a)); - const sortedB = sortRules(normalizeRuleset(b)); - if (sortedA.length !== sortedB.length) { - return false; - } - return sortedA.every((rule, index) => { - const other = sortedB[index]; - return rule.permission === other.permission - && rule.pattern === other.pattern - && rule.action === other.action; - }); -}; - -const getGlobalWildcardAction = (ruleset: PermissionRule[]): PermissionAction => { - const globalRule = ruleset.find((rule) => rule.permission === '*' && rule.pattern === '*'); - return globalRule?.action ?? 'allow'; -}; - -const filterRulesAgainstGlobal = (ruleset: PermissionRule[], globalAction: PermissionAction): PermissionRule[] => ( - normalizeRuleset(ruleset) - .filter((rule) => !(rule.permission === '*' && rule.pattern === '*')) - // Keep wildcard overrides only when they differ from global. - .filter((rule) => rule.pattern !== '*' || rule.action !== globalAction) -); - -const permissionConfigToRuleset = (value: unknown): PermissionRule[] => { - if (Array.isArray(value)) { - return normalizeRuleset(value as PermissionRule[]); - } - - if (isPermissionAction(value)) { - return [{ permission: '*', pattern: '*', action: value }]; - } - - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return []; - } - - const rules: PermissionRule[] = []; - for (const [permissionName, configValue] of Object.entries(value as Record)) { - if (permissionName === '__originalKeys') { - continue; - } - if (isPermissionAction(configValue)) { - rules.push({ permission: permissionName, pattern: '*', action: configValue }); - continue; - } - if (configValue && typeof configValue === 'object' && !Array.isArray(configValue)) { - for (const [pattern, action] of Object.entries(configValue as Record)) { - if (isPermissionAction(action)) { - rules.push({ permission: permissionName, pattern, action }); - } - } - } - } - - return rules; -}; - -const buildPermissionConfigWithGlobal = ( - globalAction: PermissionAction, - ruleset: PermissionRule[], -): AgentConfig['permission'] => { - const normalized = normalizeRuleset(ruleset); - const grouped: Record> = {}; - - for (const rule of normalized) { - (grouped[rule.permission] ||= {})[rule.pattern] = rule.action; - } - - const result: Record = {}; - - for (const [permissionName, patterns] of Object.entries(grouped)) { - if (permissionName === '*') { - continue; - } - - if (Object.keys(patterns).length === 1 && patterns['*']) { - result[permissionName] = patterns['*']; - continue; - } - - result[permissionName] = patterns; - } - - if (Object.keys(result).length === 0) { - return globalAction; - } - - if (globalAction !== 'allow') { - result['*'] = globalAction; - } - - return result as AgentConfig['permission']; -}; +import { AgentPermissionsEditor } from './AgentPermissionsEditor'; type AgentVariantProvider = { id: string; @@ -216,7 +54,6 @@ const getVariantOptionsForModel = ( }; export const AgentsPage: React.FC = () => { const { t } = useI18n(); - const { isMobile } = useDeviceInfo(); const providers = useConfigStore((state) => state.providers) as AgentVariantProvider[]; const { selectedAgentName, @@ -248,12 +85,6 @@ export const AgentsPage: React.FC = () => { const [temperature, setTemperature] = React.useState(undefined); const [topP, setTopP] = React.useState(undefined); const [prompt, setPrompt] = React.useState(''); - const [globalPermission, setGlobalPermission] = React.useState('allow'); - const [permissionBaseline, setPermissionBaseline] = React.useState([]); - const [permissionRules, setPermissionRules] = React.useState([]); - const [pendingRuleName, setPendingRuleName] = React.useState(''); - const [pendingRulePattern, setPendingRulePattern] = React.useState('*'); - const [showPermissionEditor, setShowPermissionEditor] = React.useState(false); const [isSaving, setIsSaving] = React.useState(false); const initialStateRef = React.useRef<{ draftName: string; @@ -265,238 +96,17 @@ export const AgentsPage: React.FC = () => { temperature: number | undefined; topP: number | undefined; prompt: string; - globalPermission: PermissionAction; - permissionRules: PermissionRule[]; } | null>(null); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory ?? null); - const [toolIds, setToolIds] = React.useState([]); const variantOptions = React.useMemo(() => getVariantOptionsForModel(providers, model), [model, providers]); const hasVariantOptions = variantOptions.length > 0; - const selectedVariantValue = React.useMemo(() => { - if (!variant || !variantOptions.includes(variant)) { - return '__default'; - } - return variant; - }, [variant, variantOptions]); - const shouldUseVariantSelect = hasVariantOptions && (!variant || variantOptions.includes(variant)); - - const permissionsBySession = useDirectorySync((state) => state.permission); + const selectedVariantValue = variant || '__default'; + const shouldUseVariantSelect = hasVariantOptions; + const variantSelectOptions = React.useMemo(() => ( + variant && !variantOptions.includes(variant) ? [variant, ...variantOptions] : variantOptions + ), [variant, variantOptions]); React.useEffect(() => { - let cancelled = false; - - const fetchToolIds = async () => { - const ids = await opencodeClient.listToolIds({ directory: currentDirectory }); - if (cancelled) { - return; - } - - // OpenCode permissions are keyed by tool name, but some tools are grouped - // under a single permission key. E.g. `edit` covers `write`, `patch`, and `multiedit`. - const editCoveredToolIds = new Set(['write', 'patch', 'multiedit']); - - const normalized = ids - .map((id) => (typeof id === 'string' ? id.trim() : '')) - .filter(Boolean) - .filter((id) => id !== '*') - .filter((id) => id !== 'invalid') - .filter((id) => !editCoveredToolIds.has(id)); - - setToolIds(Array.from(new Set(normalized)).sort((a, b) => a.localeCompare(b))); - }; - - void fetchToolIds(); - - return () => { - cancelled = true; - }; - }, [currentDirectory]); - - const knownPermissionNames = React.useMemo(() => { - const names = new Set(); - - for (const agent of agents) { - const rules = normalizeRuleset(permissionConfigToRuleset(agent.permission)); - for (const rule of rules) { - if (rule.permission && rule.permission !== '*' && rule.permission !== 'invalid') { - names.add(rule.permission); - } - } - } - - for (const permissions of Object.values(permissionsBySession)) { - for (const request of permissions) { - const permissionName = request.permission?.trim(); - if (permissionName && permissionName !== 'invalid') { - names.add(permissionName); - } - } - } - - for (const toolId of toolIds) { - names.add(toolId); - } - - return Array.from(names).sort((a, b) => a.localeCompare(b)); - }, [agents, permissionsBySession, toolIds]); - - const baselineRuleMap = React.useMemo(() => buildRuleMap(permissionBaseline), [permissionBaseline]); - const currentRuleMap = React.useMemo(() => buildRuleMap(permissionRules), [permissionRules]); - - const getWildcardOverride = React.useCallback((permissionName: string): PermissionAction | undefined => ( - currentRuleMap.get(buildRuleKey(permissionName, '*'))?.action - ), [currentRuleMap]); - - const getPatternRules = React.useCallback((permissionName: string): PermissionRule[] => ( - permissionRules - .filter((rule) => rule.permission === permissionName && rule.pattern !== '*') - .sort((a, b) => a.pattern.localeCompare(b.pattern)) - ), [permissionRules]); - - const summaryPermissionNames = React.useMemo(() => { - const names = new Set(); - for (const key of STANDARD_PERMISSION_KEYS) { - names.add(key); - } - for (const key of knownPermissionNames) { - names.add(key); - } - return Array.from(names).sort((a, b) => a.localeCompare(b)); - }, [knownPermissionNames]); - - const getPermissionSummary = React.useCallback((permissionName: string) => { - const defaultAction = permissionName === '*' - ? globalPermission - : (getWildcardOverride(permissionName) ?? globalPermission); - const patternRules = getPatternRules(permissionName); - const hasDefaultHint = false; - const patternCounts = patternRules.reduce>((acc, rule) => { - acc[rule.action] = (acc[rule.action] ?? 0) + 1; - return acc; - }, { allow: 0, ask: 0, deny: 0 }); - const patternSummary = (['allow', 'ask', 'deny'] as const) - .filter((action) => patternCounts[action] > 0) - .map((action) => `${patternCounts[action]} ${action}`) - .join(', '); - return { - defaultAction, - patternRulesCount: patternRules.length, - patternSummary, - hasDefaultHint, - }; - }, [getPatternRules, getWildcardOverride, globalPermission]); - const permissionActionLabel = React.useCallback((value: PermissionAction): string => { - if (value === 'allow') return t('settings.common.permission.allow'); - if (value === 'deny') return t('settings.common.permission.deny'); - return t('settings.common.permission.ask'); - }, [t]); - const permissionScopeLabel = React.useCallback((value: PermissionAction | 'global'): string => { - if (value === 'global') return t('settings.common.scope.global'); - return permissionActionLabel(value); - }, [permissionActionLabel, t]); - - const availablePermissionNames = React.useMemo(() => { - const names = new Set(); - - for (const key of STANDARD_PERMISSION_KEYS) { - names.add(key); - } - - for (const key of knownPermissionNames) { - names.add(key); - } - return Array.from(names).sort((a, b) => a.localeCompare(b)); - }, [knownPermissionNames]); - - const upsertRule = React.useCallback((permissionName: string, pattern: string, action: PermissionAction) => { - setPermissionRules((prev) => { - const map = buildRuleMap(prev); - map.set(buildRuleKey(permissionName, pattern), { permission: permissionName, pattern, action }); - return Array.from(map.values()); - }); - }, []); - - const removeRule = React.useCallback((permissionName: string, pattern: string) => { - setPermissionRules((prev) => { - const map = buildRuleMap(prev); - map.delete(buildRuleKey(permissionName, pattern)); - return Array.from(map.values()); - }); - }, []); - - const revertRule = React.useCallback((permissionName: string, pattern: string) => { - const baseline = baselineRuleMap.get(buildRuleKey(permissionName, pattern)); - if (baseline) { - upsertRule(permissionName, pattern, baseline.action); - return; - } - removeRule(permissionName, pattern); - }, [baselineRuleMap, removeRule, upsertRule]); - - const setRuleAction = React.useCallback((permissionName: string, pattern: string, action: PermissionAction) => { - upsertRule(permissionName, pattern, action); - }, [upsertRule]); - - const setGlobalPermissionAndPrune = React.useCallback((next: PermissionAction) => { - setGlobalPermission(next); - setPermissionRules((prev) => prev.filter((rule) => !(rule.pattern === '*' && rule.action === next))); - }, []); - - const applyPendingRule = React.useCallback((action: PermissionAction) => { - const name = pendingRuleName.trim(); - if (!name) { - toast.error(t('settings.agents.page.toast.permissionNameRequired')); - return; - } - - const pattern = pendingRulePattern.trim() || '*'; - if (name === '*' && pattern === '*') { - setGlobalPermissionAndPrune(action); - setPendingRuleName(''); - setPendingRulePattern('*'); - return; - } - if (pattern === '*' && name !== '*' && action === globalPermission) { - removeRule(name, '*'); - } else { - upsertRule(name, pattern, action); - } - setPendingRuleName(''); - setPendingRulePattern('*'); - }, [globalPermission, pendingRuleName, pendingRulePattern, removeRule, setGlobalPermissionAndPrune, t, upsertRule]); - - const formatPermissionLabel = React.useCallback((permissionName: string): string => { - if (permissionName === '*') return t('settings.agents.page.permissions.defaultLabel'); - if (permissionName === 'webfetch') return 'WebFetch'; - if (permissionName === 'websearch') return 'WebSearch'; - if (permissionName === 'codesearch') return 'CodeSearch'; - if (permissionName === 'doom_loop') return 'Doom Loop'; - if (permissionName === 'external_directory') return 'External Directory'; - if (permissionName === 'todowrite') return 'TodoWrite'; - if (permissionName === 'todoread') return 'TodoRead'; - - return permissionName - .split(/[_-]+/g) - .filter(Boolean) - .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) - .join(' '); - }, [t]); - - React.useEffect(() => { - setPendingRuleName(''); - setPendingRulePattern('*'); - - const applyPermissionState = (rules: PermissionRule[]) => { - const normalized = normalizeRuleset(rules); - const nextGlobal = getGlobalWildcardAction(normalized); - const filtered = filterRulesAgainstGlobal(normalized, nextGlobal); - setGlobalPermission(nextGlobal); - setPermissionBaseline(filtered); - setPermissionRules(filtered); - return { global: nextGlobal, rules: filtered }; - }; - if (isNewAgent && agentDraft) { const draftNameValue = agentDraft.name || ''; const draftScopeValue = agentDraft.scope || 'user'; @@ -518,9 +128,6 @@ export const AgentsPage: React.FC = () => { setTopP(topPValue); setPrompt(promptValue); - const parsedRules = permissionConfigToRuleset(agentDraft.permission); - const permissionState = applyPermissionState(parsedRules); - initialStateRef.current = { draftName: draftNameValue, draftScope: draftScopeValue, @@ -531,8 +138,6 @@ export const AgentsPage: React.FC = () => { temperature: temperatureValue, topP: topPValue, prompt: promptValue, - globalPermission: permissionState.global, - permissionRules: permissionState.rules, }; return; } @@ -544,8 +149,8 @@ export const AgentsPage: React.FC = () => { ? `${selectedAgent.model.providerID}/${selectedAgent.model.modelID}` : ''; const variantValue = selectedAgent.variant || ''; - const temperatureValue = selectedAgent.temperature; - const topPValue = selectedAgent.topP; + const temperatureValue = selectedAgent.temperature ?? undefined; + const topPValue = selectedAgent.topP ?? undefined; const promptValue = selectedAgent.prompt || ''; setDescription(descriptionValue); @@ -557,10 +162,6 @@ export const AgentsPage: React.FC = () => { setTopP(topPValue); setPrompt(promptValue); - const permissionState = applyPermissionState( - permissionConfigToRuleset(selectedAgent.permission), - ); - initialStateRef.current = { draftName: '', draftScope: 'user', @@ -571,8 +172,6 @@ export const AgentsPage: React.FC = () => { temperature: temperatureValue, topP: topPValue, prompt: promptValue, - globalPermission: permissionState.global, - permissionRules: permissionState.rules, }; } }, [agentDraft, isNewAgent, selectedAgent, selectedAgentName]); @@ -595,11 +194,9 @@ export const AgentsPage: React.FC = () => { if (temperature !== initial.temperature) return true; if (topP !== initial.topP) return true; if (prompt !== initial.prompt) return true; - if (globalPermission !== initial.globalPermission) return true; - if (!areRulesEqual(permissionRules, initial.permissionRules)) return true; return false; - }, [description, draftName, draftScope, globalPermission, isNewAgent, mode, model, permissionRules, prompt, temperature, topP, variant]); + }, [description, draftName, draftScope, isNewAgent, mode, model, prompt, temperature, topP, variant]); const handleSave = async () => { const agentName = isNewAgent ? draftName.trim().replace(/\s+/g, '-') : selectedAgentName?.trim(); @@ -621,7 +218,6 @@ export const AgentsPage: React.FC = () => { const trimmedModel = model.trim(); const trimmedVariant = variant.trim(); const trimmedPrompt = prompt.trim(); - const permissionConfig = buildPermissionConfigWithGlobal(globalPermission, permissionRules); const config: AgentConfig = { name: agentName, ...(description.trim() ? { description: description.trim() } : {}), @@ -631,7 +227,6 @@ export const AgentsPage: React.FC = () => { temperature: temperature ?? null, top_p: topP ?? null, prompt: trimmedPrompt || (isNewAgent ? undefined : null), - permission: permissionConfig, ...(isNewAgent && draftScope ? { scope: draftScope } : {}), }; @@ -676,552 +271,263 @@ export const AgentsPage: React.FC = () => { } return ( - -
- - {/* Header & Actions */} -
-
-

- {isNewAgent ? t('settings.agents.page.title.new') : selectedAgentName} -

-

- {isNewAgent ? t('settings.agents.page.subtitle.new') : t('settings.agents.page.subtitle.edit')} -

-
-
- - {/* Identity & Role */} -
-
-

- {t('settings.agents.page.section.identityRole')} -

-
- -
- - {isNewAgent && ( -
-
- {t('settings.agents.page.field.agentName')} -
-
-
- @ - setDraftName(e.target.value)} - placeholder={t('settings.agents.page.field.agentNamePlaceholder')} - className="h-7 w-40 px-2" - /> + + + {isNewAgent && ( + +
+ @ + setDraftName(e.target.value)} + placeholder={t('settings.agents.page.field.agentNamePlaceholder')} + className="h-7 w-40 px-2" + /> +
+ setDraftScope(v as AgentScope)}> - - - - - -
- - {t('settings.common.scope.global')} -
-
- -
- - {t('settings.common.scope.project')} -
-
-
- -
-
- )} - -
- {t('settings.common.field.description')} -
-