Three layout options for the inline model + thinking control. Recommendation marked below. Theme tokens only.
+
+
+
+
+
+
+
+
+
The fixes
What each option must deliver, verified visually below.
+
1 · No ellipsis on key info
Full model name + source always readable.
+
2 · Readable origin
Built-in / Custom / Pack spelled out, never “…”.
+
3 · Source = information
Inheritance is flat muted text, not a fake button.
+
4 · Consistent reset
Model + thinking reset live in the same place, same affordance.
+
5 · Compact + professional
Density preserved; works down to narrow widths.
+
+
+
+
+
+
+
Option A — Control box + quiet source caption Recommended
+
+ Keep the existing renderModelRow control box exactly as-is, but (a) reset lives inside the box
+ for both model and thinking (identical small × buttons, only shown when that field is overridden),
+ and (b) attribution moves to one flat caption line below — leading ↳, plain muted text, no pills, no borders, no hover.
+ Overridden fields are signalled by the box’s primary tint plus a quiet “● Override” word. Widening the list to ~820px
+ removes all truncation of model names and source text.
+
+
+
+
+
+
+
Why A is recommended
+
+
Smallest delta from production. Reuses the current control box + source-line slot; only the reset placement and caption styling change.
+
Reset is unambiguous & symmetric. Model × and thinking × are the same control in the same box (fix #4). The caption never carries a button (fix #3).
+
One scan line per concept. Row 1 = what it is + what it runs; caption = where that comes from. Full names, no “…” (fixes #1, #2, #5).
+
+
+
+
+
+
+
+
+
+
Option B — Two explicit labelled field rows
+
+ Model and Thinking each get their own KEY · control · source line. Most explicit and self-documenting,
+ and reset is obviously per-field. Cost: taller rows (≈2× height) and it drifts further from the current single-control
+ layout, so it needs more new CSS. Good if we want maximum clarity over density.
+
+
+
+
+
+
+
+
+
+
+
+
+
Option C — Compact single line
+
+ Everything on one line: control box + trailing muted source text. Densest option, closest to today’s height.
+ The trade-off: at this density the source text has to stay terse and the model box still competes for width, so it’s
+ the most likely to clip on narrow viewports. Reset is in-box (same as A) for consistency.
+
+
+
+
+
+
+
+
+
+
+
+
+
Narrow-width behaviour (Option A @ 360px)
+
+ At narrow widths the control wraps beneath the identity, the slug hides, the model box goes full-width, and the source
+ caption wraps to its own line. Edit/delete stay reachable on the header line. Key info still never relies on “…”.
+
Same control: rows reuse the renderModelRow box (model picker, divider, thinking select). No new picker.
+
Same tokens: override tint is color-mix(in oklch, var(--primary) 45/8%, …) — already in .role-row-model-control--override.
+
Same badges: origin badge mirrors renderOriginBadge pill shape; source line replaces the .rrm-badge pills with flat text so nothing reads as a button.
+
Reset relocation: the only structural change vs. today is moving the thinking reset out of the caption and into the control box next to the model reset, so both are data-testid-able as in-box buttons.
+
Stable hooks: keep data-testid="role-row-model-control", data-model-state, role-row-model-source, role-row-thinking-source; thinking reset moves to an in-box model-thinking-clear-btn hook.
+
+
+
+
+
+
+
+
diff --git a/docs/design/per-role-model-overrides.md b/docs/design/per-role-model-overrides.md
index 2ed58afcc..dac41fb08 100644
--- a/docs/design/per-role-model-overrides.md
+++ b/docs/design/per-role-model-overrides.md
@@ -589,3 +589,121 @@ model under a different id" class of bug.
| `tests/manual-integration/role-model-override.test.ts` | Manual binding-verification test. |
| `AGENTS.md` | Add a Recipes bullet: **"Per-role model override"** → role yaml `model:`/`thinkingLevel:`; bound at session start by `tryAutoSelectModel` / `tryApplyDefaultThinkingLevel` (`session-manager.ts:2814`); reviewer/QA path in `verification-harness.ts` 3 sites. |
| `docs/internals.md` | Brief note under "Per-project config" or a new "Per-role model" subsection cross-linking this design doc. |
+
+---
+
+## 8. Roles Model UI Reshuffle & Save-Hang Fix
+
+To make per-role model and thinking level overrides visible at a glance and editable in one place, the Roles UI was reshuffled (not redesigning the underlying model) to introduce list-row inline model controls, a relocated detail-page Model section, and a bug-fix for a save-button race condition.
+
+### 8.1 Roles List View Inline Controls & Heuristic
+
+The Roles list page (`src/app/role-manager-page.ts`) now renders an inline model + thinking level control in the middle of each list row by reusing the existing `renderModelRow` component.
+
+* **Auto-Save on Change:** Interacting with the inline control (selecting a model, changing thinking level, or clearing an override) immediately persists the change to the backend via `updateRole` (scoped to the current project). A transient `"Saved"` confirmation badge briefly flashes next to the role name on success.
+* **Propagation Stopping:** Events (`click`, `keydown`) inside the inline control container call `stopPropagation` to prevent triggering the outer list-row's click handler (which navigates to the role editor).
+* **Overridden State:** When a role override is active, the model picker displays with an filled accent style, the thinking dropdown is fully interactive, and both clear (`×`) and Test (flask) buttons are exposed.
+* **Inherited State Heuristic:** When no role override exists, the list row displays a resolved, greyed-out effective default model label formatted as ` · default` with the thinking dropdown disabled and clear/Test buttons hidden. Since inherited default resolution is contextual at session-start (reviewer sessions use review defaults, others use session defaults), the list UI uses a deterministic display-only heuristic:
+ * **Review Default Allowlist (`default.reviewModel`):** `architect`, `code-reviewer`, `reviewer`, `security-reviewer`, `spec-auditor`, and `qa-tester` display the review-model default.
+ * **Session Default (`default.sessionModel`):** All other roles (including custom roles) display the session-model default.
+
+### 8.2 Role Detail Editor Relocation
+
+In the role detail editor (`renderRoleEditor`):
+
+* **Removed Tab:** The dedicated "Model" tab is removed; the tab bar now exposes only "Prompt" and "Tool Access".
+* **Static Section:** Model and thinking controls are presented as a static section placed between the **Accessory** selector and the tab bar, styled identically to the Accessory selector.
+* **Draft-Based Saves:** Unlike the list page, modifications within the detail-page Model section do not auto-save. They are draft-based and only committed when the user clicks the main **Save** button.
+* **Read-Only Inspector:** The read-only inspector (`renderRoleInspector` used in the goal-proposal modal) is updated to reflect the new vertical layout, rendering the Model section as read-only.
+
+### 8.3 Save-Hang Bug Fix
+
+* **Root Cause:** Inside the `handleSave` function of `role-manager-page.ts`, the successful PUT path calls `showEdit(updated)` -> `setHashRoute("role-edit", name)`. If the user saved changes while already on that role's edit page, `window.location.hash` was already current. Consequently, `setHashRoute` was a no-op that did not trigger a `hashchange` event. Because rendering was only bound to route/hash transitions, the DOM was never refreshed; the Save button remained stuck showing "Saving…" even though `saving` was set to `false` in memory.
+* **Fix:** Explicitly call `renderApp()` in the success path of `handleSave` (after `showEdit`/`showList`) so that the DOM is guaranteed to re-render and return the button to the idle "Save" state regardless of whether the hash changed.
+
+### 8.4 Test Coverage
+
+* **UI Fixture Tests (`tests/ui-fixtures/settings-admin-fixture.spec.ts`):**
+ * Asserts inherited list rows display the correct default model label according to the allowance heuristic (e.g., `coder` shows session default, `reviewer` shows review default).
+ * Asserts inline model selection auto-saves, flips row state to override, reveals clear/Test buttons, does not trigger edit page navigation, and survives page reloads.
+ * Asserts clearing an override reverts the row back to the inherited display state and clears backend fields.
+ * Asserts the detail page vertical section ordering (Accessory -> Model section -> Tab bar) and draft-based saving.
+ * Asserts the Save button returns to idle "Save" after saving (regression check for the save-hang).
+* **Browser E2E (`tests/e2e/ui/roles-model-reshuffle.spec.ts`):**
+ * Real browser-driven spec verifying section layout ordering, tab bar composition (exposing only Prompt and Tool Access), and the Save button transition lifecycle under direct text changes on a real gateway.
+* **Marketplace E2E (`tests/e2e/ui/marketplace.spec.ts`):**
+ * Adjusted row-edit navigation. Because list rows now host interactive inline model controls, the test specifically clicks the explicit Pencil Edit action (`button[title="Edit"]`) in the row instead of clicking anywhere on the row.
+
+---
+
+## 9. Polished Roles Model Row Controls & Source Hierarchy (Release 0.14)
+
+### 9.1 Server-Side Metadata and Cascade Resolution (`modelResolution`)
+
+The server exposes granular field-level model/thinking resolution metadata via `resolveRoleModelResolution(roleName, projectId)` in `src/server/agent/config-cascade.ts`. This goes beyond flat role definitions to provide the client with the precise source, origin, and editability of each field.
+
+#### Source Hierarchy
+The resolution engine walks the cascade (highest priority to lowest):
+1. **Direct Role Override:** User-defined override at the current scope (Project or Server-wide).
+2. **Inherited Role Override:** An override defined at an ancestor project layer, the server-wide layer, or via built-in / market-pack defaults.
+3. **Effective Defaults:** If no role layer provides a value, it falls back to global preferences (`default.sessionModel` or `default.reviewModel` based on role type).
+
+#### Heuristic for Default Models
+Since inherited model resolution is contextual at session-start, the UI uses a deterministic display-only heuristic to determine the fallback default:
+* **Review Defaults (`default.reviewModel`):** Applied to review-centric roles (`architect`, `code-reviewer`, `reviewer`, `security-reviewer`, `spec-auditor`, and `qa-tester`).
+* **Session Defaults (`default.sessionModel`):** Applied to all other roles, including custom-created roles.
+* **Auto Default:** Rendered when no global defaults are configured (the AI gateway auto-selects a model).
+
+#### Metadata Fields
+GET `/api/roles` and GET `/api/roles/:name` attach a `modelResolution` object mapping `model` and `thinkingLevel` to `RoleFieldSource` descriptors:
+* `value`: The resolved model string (e.g. `anthropic/claude-opus-4-8`) or thinking level.
+* `source`: `"role"` (current scope), `"inherited-role"` (inherited scope), or `"default"` (fallback).
+* `origin`: `"project"`, `"server"`, or `"builtin"`.
+* `originPackName`: The name of the source market-pack, if applicable.
+* `editable`: Boolean indicating if editing is permitted. Gated by pack-managed status (pack roles are read-only) or whether a higher-precedence winner (like a global server user-override) shadows this scope, preventing confusing no-ops.
+* `sourceLabel`: Clean human-friendly label (e.g., `Project`, `Server`, `Built-in`, `Inherited project`, or market pack name).
+
+---
+
+### 9.2 Main Roles List Row Behavior & Visual Polish
+
+The list row inline controls inside `role-manager-page.ts` are designed to be extremely compact, source-aware, and aligned.
+
+#### Inline Selector Layout
+* **Two-Line Layout:** Line 1 hosts the interactive selectors (reusing a compact variant wrapper of `renderModelRow` with contained chevrons). Line 2 houses the compact, monochromatic source badges below the selectors, aligning with the layout without overflowing boundaries or wasting vertical space.
+* **Instant Auto-Save:** Interacting with a selector immediately persists the change to the backend via `updateRole` (scoped to the current project context). On success, a transient green `"Saved"` flash badge briefly renders next to the role label.
+* **Rapid Save Coalescing (Queue & Lock):** To guard against race conditions—such as a user changing model and immediately changing thinking level before the network fetch completes—a per-role pending edit map (`pendingInlineEdits`) and lock set (`inlineSaveActive`) implement a sequential save queue. Edits are merged and written sequentially, preventing stale-state overwrites.
+* **Independent Field Overrides:** Model and thinking can be overridden and cleared independently. A role can have a thinking-only override with no visible model override. Clearing the model override via the `×` button preserves any custom thinking level (falling back to the default/inherited model) and vice-versa.
+* **Affordance Precision:** Clear and reset buttons are only rendered when there is an active current-scope override (`source === "role"`). If the field is inherited or default, no reset button is shown, and the dropdown shows `(use default)`, preventing confusing no-ops.
+* **Event Isolation:** Select container events call `stopPropagation()` to prevent triggering the parent list-row click action, ensuring users do not accidentally navigate to the details editor when adjusting models.
+* **Market Pack / Above-Current-Scope Read-Only:** Read-only roles render static effective labels plus a badge like `Managed by pack ` or `Read-only`, completely preventing inline modification.
+
+---
+
+### 9.3 Detail Editor Behavior & Section Relocation
+
+The role detail page (`renderRoleEditor`):
+* **Tab Simplification:** The separate "Model" tab has been removed; only the "Prompt" and "Tool Access" tabs remain.
+* **Vertical Section Ordering:** Model and thinking controls are now laid out as a static vertical section positioned between the **Accessory** grid and the tab bar.
+* **Draft-Based Saves:** Unlike the list row's immediate auto-saves, edits in the detail editor Model section are strictly draft-based. They are only committed to the backend when the user clicks the main **Save** button in the header.
+
+---
+
+### 9.4 Visual QA Evidence & Checklist
+
+The following visual assertions have been verified in the development environment and are pinned by automated tests (`tests/e2e/ui/roles-model-reshuffle.spec.ts` and `tests/ui-fixtures/settings-admin-fixture.spec.ts`).
+
+#### Visual Verification Checklist
+1. **Compact Rows:** Selectors and source badges are aligned, compact, and fit neatly inside the list rows. No overflow or layout brokenness.
+2. **Source Badges Visible:** Labels like `Role override` (green), `Inherited role override · Server` (blue/grey), or `Session default` (grey) render correctly on line 2.
+3. **Model & Thinking Always Visible:** The current effective labels (e.g. `claude-opus-4-8 · default` or overridden names) are legible.
+4. **Chevrons Contained:** Dropdown arrows are perfectly positioned inside the controls.
+5. **Responsive Action Buttons:** The Pencil (Edit) and Trash (Delete) buttons on the extreme right of the row remain fully reachable and clickable even at narrow viewport widths.
+6. **Save-Hang Guard:** Detail editor's Save button correctly reverts to the idle "Save" state (not stuck on "Saving...") after a successful save.
+
+#### Visual Screenshots
+Screenshots captured during visual validation can be found in:
+* `.bobbit/tmp/roles-list-inherited.png` — Default and inherited role rows with greyed-out default indicators and disablement.
+* `.bobbit/tmp/roles-list-overridden.png` — Active role overrides displaying interactive selectors and clear (`×`) / Test (flask) buttons.
+* `.bobbit/tmp/roles-detail-layout.png` — Polished detail editor vertical structure with the static Model section.
+
diff --git a/docs/internals.md b/docs/internals.md
index a4e465054..a25f175d8 100644
--- a/docs/internals.md
+++ b/docs/internals.md
@@ -1432,9 +1432,61 @@ This is what makes "my `code-reviewer` role always runs on opus" work without ch
**Naming model is explicitly unaffected** - `default.namingModel` and `pickFallbackAigwNamingModel` still drive title generation regardless of role.
-### UI
+### UI & API Metadata
-The role-manager page (`src/app/role-manager-page.ts`) has a third tab next to **Prompt** and **Tool Access**, labelled **Model**. It reuses the model picker and thinking dropdown components from the settings page, with a leading "(use default)" option that maps to the empty string → omitted from YAML. The standard origin badge / Customize / Revert flow operates on the whole role record, so touching either field flips builtin→overridden and Revert clears them along with any other overrides.
+The Roles model and thinking configuration integrates directly across both list and detail views, driven by server-resolved metadata.
+
+#### 1. Server-Side API Metadata (`modelResolution`)
+
+To enable accurate, source-aware rendering of effective values and inheritance, the `/api/roles` endpoints (both GET list and GET single role) return additive `modelResolution` metadata. This is computed by `resolveRoleModelResolution(roleName, projectId)` in `src/server/agent/config-cascade.ts`.
+
+It provides a granular, field-level audit of both `model` and `thinkingLevel`:
+
+```ts
+export interface RoleModelResolution {
+ model: RoleFieldSource;
+ thinkingLevel: RoleFieldSource;
+}
+
+export interface RoleFieldSource {
+ /** Resolved field value when a role layer supplies it; omitted for `default` */
+ value?: string;
+ source: "role" | "inherited-role" | "default";
+ /** Cascade layer the value came from (omitted for `default`) */
+ origin?: "project" | "server" | "builtin";
+ /** Market-pack name when the value comes from a pack-defined role; null otherwise */
+ originPackName?: string | null;
+ /** False only for pack-managed/read-only roles or when shadowed above current scope; true otherwise */
+ editable: boolean;
+ /** Short human label for the source ("Project", "Server", "Built-in", or pack name) */
+ sourceLabel: string;
+}
+```
+
+The metadata ensures that:
+* **Shadowing is respected:** If a higher-precedence winner (e.g. a global server-user override) shadows the current project scope, the server reports the field as non-editable (`editable: false`). The UI displays it without misleading clear/reset or edit affordances that would secretly no-op.
+* **Source labels are accurate:** Clear distinction between direct local overrides (`source: "role"`), inherited layers (`source: "inherited-role"`), or system-wide fallbacks (`source: "default"`).
+
+#### 2. Roles List View Inline Controls
+
+The main Roles page (`src/app/role-manager-page.ts`) renders compact, two-line model + thinking level inline controls in the middle of each list row by reusing a compact layout variant of the `renderModelRow` component:
+
+* **Two-Line Layout:** Line 1 displays the interactive model selector and thinking level dropdown. Line 2 displays compact, monochromatic source badges (e.g., `Model: [Session default] Thinking: [Session default]` or `[Inherited role override · Server]`).
+* **Auto-Save on Change:** Interacting with the inline control immediately persists the changes to the backend. On success, a transient green `"Saved"` confirmation badge briefly flashes next to the role name.
+* **Coalesced Saves (Race Guard):** Rapid sequential edits (such as picking a model and immediately changing thinking before the network refetch completes) are queued via `pendingInlineEdits` and handled sequentially in a serialized save loop (`scheduleInlineSave`). This prevents a newer change from reading stale data from the un-refetched row object and clobbering the previous field with empty strings.
+* **Independent Overrides & Model Clears:** Model and thinking level can be overridden independently. Clicking the clear (`×`) button next to the model selector removes only the model override, preserving any custom thinking override (shown as a `thinking-override` state), and vice-versa.
+* **No Misleading Reset Affordances:** Clearing and reset buttons are only exposed for active current-scope overrides (`source === "role"`). Inherited or default values display the placeholder label `(use default)` and have no clear buttons, avoiding confusing no-ops.
+* **Propagation Stopping:** Dropdown click/keydown events are stopped via `stopPropagation` so that interacting with the selectors does not trigger the outer row's click-to-edit navigation.
+* **Read-Only/Pack Rows:** Roles installed from a Market Pack (or non-editable due to scope shadow rules) render a static effective-value summary (Model + Thinking labels) alongside a badge like `Managed by pack ` or `Read-only`, completely disabling inline editing.
+
+#### 3. Role Detail Editor Relocation
+
+In the role detail editor (`renderRoleEditor`):
+
+* **Removed Tab:** The dedicated "Model" tab is removed; the tab bar now exposes only "Prompt" and "Tool Access".
+* **Static Section:** Model and thinking controls are presented as a static section placed vertically between the **Accessory** selector and the tab bar, styled identically to the Accessory selector.
+* **Draft-Based Saves:** Unlike the list page's immediate auto-saves, modifications within the detail-page Model section do not auto-save. They are draft-based and are only committed when the user clicks the main **Save** button in the top navigation bar.
+* **Read-Only Inspector:** The read-only inspector (`renderRoleInspector` used in the goal-proposal modal) mimics this vertical layout, rendering the Model section as read-only.
---
diff --git a/docs/rest-api.md b/docs/rest-api.md
index 3206cb8a7..57b0ee301 100644
--- a/docs/rest-api.md
+++ b/docs/rest-api.md
@@ -463,9 +463,9 @@ See [docs/cache-hit-rate.md](cache-hit-rate.md) for full formula and null semant
| Method | Path | Description |
|---|---|---|
-| `GET` | `/api/roles` | List all roles |
+| `GET` | `/api/roles` | List all roles. Response roles include the resolved `modelResolution` field-level source and inheritance metadata. |
| `POST` | `/api/roles` | Create a role (`{ name, label, promptTemplate, toolPolicies?, allowedTools?, accessory?, model?, thinkingLevel? }`). `toolPolicies` is the source of truth for tool access; `allowedTools` is accepted for backward compatibility and merged as `always-allow` entries. `model` is `"/"` format and overrides `default.sessionModel` / `default.reviewModel` for sessions of this role. `thinkingLevel` is one of `"off" | "minimal" | "low" | "medium" | "high" | "xhigh"` (clamped to the role's model capability at write time when `model` is set; see [docs/thinking-levels.md](thinking-levels.md)). |
-| `GET` | `/api/roles/:name` | Get a role (includes `toolPolicies` and derived `allowedTools`) |
+| `GET` | `/api/roles/:name` | Get a role (includes `toolPolicies`, derived `allowedTools`, and resolved `modelResolution` field-level source and inheritance metadata) |
| `PUT` | `/api/roles/:name` | Update a role. Accepts `toolPolicies` (Record of tool/group name → policy), `model` (`"/"`), and `thinkingLevel` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh"`, clamped to the role's model capability when `model` is set). Policy values are validated against: `always-allow`, `ask-once`, `always-ask`, `never-ask`, `never`. Malformed `model` strings are rejected with 400. |
| `DELETE` | `/api/roles/:name` | Delete a role |
diff --git a/src/app/api.ts b/src/app/api.ts
index 47e6b390e..1d291f554 100644
--- a/src/app/api.ts
+++ b/src/app/api.ts
@@ -2361,6 +2361,34 @@ export async function enqueueInboxManual(
// ROLE API
// ============================================================================
+/**
+ * Where a role's `model` / `thinkingLevel` field resolved from in the config
+ * cascade, relative to the currently-editable scope. Emitted by the server on
+ * `/api/roles` + `/api/roles/:name` so the Roles list/detail can render an
+ * accurate source badge and decide inline editability without re-deriving the
+ * cascade order. See `RoleFieldSource` in `src/server/agent/config-cascade.ts`.
+ */
+export type RoleFieldSourceKind = "role" | "inherited-role" | "default";
+
+export interface RoleFieldSource {
+ /** Resolved field value when a role layer supplies it; omitted for `default`. */
+ value?: string;
+ source: RoleFieldSourceKind;
+ /** Cascade layer the value came from (omitted for `default`). */
+ origin?: "builtin" | "server" | "user" | "project";
+ /** Market-pack name when the value comes from a pack-defined role; null otherwise. */
+ originPackName?: string | null;
+ /** False only for pack-managed (read-only) roles; true otherwise. */
+ editable: boolean;
+ /** Short human label for the source ("Project", "Server", "Built-in", pack name…). */
+ sourceLabel: string;
+}
+
+export interface RoleModelResolution {
+ model: RoleFieldSource;
+ thinkingLevel: RoleFieldSource;
+}
+
export interface RoleData {
name: string;
label: string;
@@ -2373,6 +2401,12 @@ export interface RoleData {
thinkingLevel?: string;
createdAt: number;
updatedAt: number;
+ /**
+ * Per-field source hierarchy for model/thinkingLevel (cascade origin +
+ * editability). Present on cascade-resolved role payloads from `/api/roles`
+ * and `/api/roles/:name`; absent on locally-constructed role drafts.
+ */
+ modelResolution?: RoleModelResolution;
}
// ============================================================================
diff --git a/src/app/proposal-panels.ts b/src/app/proposal-panels.ts
index 3d96802f5..8db3162d5 100644
--- a/src/app/proposal-panels.ts
+++ b/src/app/proposal-panels.ts
@@ -572,9 +572,9 @@ interface GoalFormConfig {
onCustomizeRole?: () => void;
onResetRole?: () => void;
onRoleDraftChange?: (patch: Partial) => void;
- onRoleEditorTabChange?: (tab: "prompt" | "tools" | "model") => void;
+ onRoleEditorTabChange?: (tab: "prompt" | "tools") => void;
onRoleToggleToolGroup?: (group: string) => void;
- roleEditTab?: "prompt" | "tools" | "model";
+ roleEditTab?: "prompt" | "tools";
roleCollapsedGroups?: ReadonlySet;
roleList?: RoleData[];
roleListLoading?: boolean;
@@ -2827,7 +2827,7 @@ let _proposalInlineRoles: Record = {};
let _proposalSelectedRoleName: string | null = null;
let _proposalCustomizingWorkflow = false;
let _proposalCustomizingRole = false;
-let _proposalRoleEditTab: "prompt" | "tools" | "model" = "prompt";
+let _proposalRoleEditTab: "prompt" | "tools" = "prompt";
let _proposalRoleCollapsedGroups = new Set();
let _proposalTabsInitializedFrom: string | null = null;
// Role data caches for the modal, project-scoped: roles can be customised per
diff --git a/src/app/role-manager-page.ts b/src/app/role-manager-page.ts
index 1a9600ad1..4ef23a60d 100644
--- a/src/app/role-manager-page.ts
+++ b/src/app/role-manager-page.ts
@@ -4,7 +4,7 @@ import { Button } from "@mariozechner/mini-lit/dist/Button.js";
import { Input } from "@mariozechner/mini-lit/dist/Input.js";
import { html, nothing, type TemplateResult } from "lit";
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide";
-import { fetchTools, updateRole, deleteRole, gatewayFetch, fetchAssistantPrompts, updateAssistantPrompt, fetchGroupPolicies, type RoleData, type ToolInfo, type AssistantPromptInfo } from "./api.js";
+import { fetchTools, updateRole, deleteRole, gatewayFetch, fetchAssistantPrompts, updateAssistantPrompt, fetchGroupPolicies, type RoleData, type ToolInfo, type AssistantPromptInfo, type RoleFieldSource } from "./api.js";
import { errorFromResponse, errorDetails } from "./error-helpers.js";
import { connectToSession } from "./session-manager.js";
import { showConnectionError, confirmAction } from "./dialogs.js";
@@ -13,7 +13,7 @@ import { renderIdleBlobCanvas } from "../ui/bobbit-render.js";
import { state, renderApp } from "./state.js";
import { setHashRoute } from "./routing.js";
import { type ConfigOrigin, getConfigScope, setConfigScope, getConfigProjectId, renderOriginBadge, isInherited, renderConfigScopeRow, customizeItem, revertOverride, getCurrentProjectName } from "./config-scope.js";
-import { renderModelRow, formatModelPref } from "./settings-page.js";
+import { renderModelRow, formatModelPref, getRoleDefaultModel, getRoleDefaultModelPrefKey, ensureModelDefaultsLoaded } from "./settings-page.js";
// ============================================================================
// HELPERS
@@ -63,11 +63,28 @@ let editAccessory = "none";
let editToolPolicies: Record = {};
let editModelOverride = "";
let editThinkingOverride = "";
-let editTab: "prompt" | "tools" | "model" = "prompt";
+let editTab: "prompt" | "tools" = "prompt";
let saving = false;
let deleting = false;
+// Inline list-row model auto-save: name of the role currently showing a
+// transient "Saved" flash next to its label, plus the clear-timer handle.
+let savedModelFlashRole: string | null = null;
+let savedModelFlashTimer: ReturnType | null = null;
+
+// Inline list-row save race guard. Inline model/thinking edits are auto-saved
+// asynchronously, but a save round-trips through customize + PUT + a full
+// refetch before the row objects refresh. If a user picks a model and then
+// quickly changes thinking, the second handler would otherwise read the model
+// off the STALE row object (still showing no override) and clobber the
+// just-selected model with "". To prevent that we keep a per-role pending draft
+// (the latest desired { model, thinkingLevel } for that scope) and a per-role
+// serialized save loop that coalesces rapid edits into ordered PUTs built from
+// the merged draft rather than from any stale row snapshot.
+const pendingInlineEdits = new Map();
+const inlineSaveActive = new Set();
+
// Group policies loaded from server
let groupPolicies: Record = {};
@@ -117,6 +134,9 @@ export async function loadRolePageData(): Promise {
saving = false;
deleting = false;
renderApp();
+ // Load default model prefs + model registry so inherited list rows can show
+ // a resolved " · default" label without requiring a Settings visit.
+ ensureModelDefaultsLoaded();
const [r, t, gp] = await Promise.all([fetchRolesScoped(), fetchTools(), fetchGroupPolicies()]);
roles = r;
availableTools = t;
@@ -254,6 +274,11 @@ async function handleSave(): Promise {
const updated = roles.find((r) => r.name === selectedRole!.name);
if (updated) showEdit(updated);
else showList();
+ // Explicitly rerender: showEdit -> setHashRoute("role-edit", name) is a
+ // no-op when the hash already matches (saving from the role's own edit
+ // page), so no hashchange fires and the "Saving…" button would otherwise
+ // stay stuck. initEditState already reset saving=false in memory.
+ renderApp();
return;
}
}
@@ -261,6 +286,99 @@ async function handleSave(): Promise {
renderApp();
}
+/**
+ * Persist an inline list-row model/thinking change for a role and flash "Saved".
+ * Uses the full role payload required by the PUT path. On failure, `updateRole`
+ * has already surfaced the error via `showConnectionError`; we just rerender.
+ */
+async function persistInlineModel(role: RoleData, model: string, thinkingLevel: string): Promise {
+ const projectId = getConfigProjectId();
+ // Project scope: an inherited role has no project-layer override yet, so the
+ // PUT /api/roles/:name?projectId= path 404s ("Role not found in project").
+ // Copy the resolved role into the project layer first so every editable list
+ // row can be saved inline. System-scope builtins are auto-promoted to a
+ // server override by the PUT handler, so no explicit customize is needed there.
+ if (projectId && isInherited((role as any).origin as ConfigOrigin | undefined)) {
+ const customized = await customizeItem("roles", role.name, "project", projectId);
+ if (!customized) {
+ renderApp();
+ return;
+ }
+ }
+ const ok = await updateRole(role.name, {
+ label: role.label,
+ promptTemplate: role.promptTemplate,
+ accessory: role.accessory,
+ toolPolicies: role.toolPolicies ?? {},
+ model,
+ thinkingLevel,
+ }, projectId || undefined);
+ if (!ok) {
+ renderApp();
+ return;
+ }
+ roles = await fetchRolesScoped();
+ savedModelFlashRole = role.name;
+ renderApp();
+ if (savedModelFlashTimer) clearTimeout(savedModelFlashTimer);
+ savedModelFlashTimer = setTimeout(() => {
+ savedModelFlashRole = null;
+ savedModelFlashTimer = null;
+ if (currentView === "list") renderApp();
+ }, 1800);
+}
+
+/**
+ * Merge a single inline field change into the role's pending draft and kick off
+ * (or coalesce into) its serialized save loop. The draft base is the role's
+ * existing pending draft when one is in flight, else the current-scope
+ * overrides off the freshly-rendered row — so the "model picked, then thinking
+ * changed before refetch" race never reads a stale model/thinking off the row.
+ */
+function queueInlineEdit(role: RoleData, patch: { model?: string; thinkingLevel?: string }): void {
+ const base = pendingInlineEdits.get(role.name) ?? currentScopeRoleOverrides(role);
+ pendingInlineEdits.set(role.name, {
+ model: patch.model ?? base.model,
+ thinkingLevel: patch.thinkingLevel ?? base.thinkingLevel,
+ });
+ scheduleInlineSave(role.name);
+}
+
+/**
+ * Per-role serialized save loop. Drains the pending draft for `name` one PUT at
+ * a time; a newer edit that arrives mid-save is picked up on the next loop turn
+ * rather than racing the in-flight save. Always builds the PUT from the latest
+ * fetched row object (so origin/customize state is fresh) merged with the
+ * pending model/thinking draft.
+ */
+function scheduleInlineSave(name: string): void {
+ if (inlineSaveActive.has(name)) return; // a loop is already draining this role
+ inlineSaveActive.add(name);
+ void (async () => {
+ try {
+ let desired = pendingInlineEdits.get(name);
+ while (desired) {
+ // Use the freshest row object so a save that already promoted the
+ // role into the project layer is not re-customized, and label/prompt/
+ // accessory/tools reflect the latest fetch.
+ const current = roles.find(r => r.name === name);
+ if (!current) { pendingInlineEdits.delete(name); break; }
+ await persistInlineModel(current, desired.model, desired.thinkingLevel);
+ const after = pendingInlineEdits.get(name);
+ if (after && after.model === desired.model && after.thinkingLevel === desired.thinkingLevel) {
+ // No newer edit arrived during the save — drain complete.
+ pendingInlineEdits.delete(name);
+ desired = undefined;
+ } else {
+ desired = after; // a newer edit landed mid-save; persist it next.
+ }
+ }
+ } finally {
+ inlineSaveActive.delete(name);
+ }
+ })();
+}
+
async function handleDelete(): Promise {
if (!selectedRole) return;
const confirmed = await confirmAction(
@@ -309,6 +427,10 @@ export interface RoleListOptions {
scope?: RoleRendererScope;
/** Optional empty-state action button shown when roles is empty. */
emptyAction?: TemplateResult;
+ /** When set, list rows render an inline model+thinking control with
+ * auto-save wired to these callbacks. Omitted by list-only callers
+ * (e.g. the goal-draft modal). */
+ modelControl?: RoleRowModelControl;
}
export interface RoleEditorDraft {
@@ -318,12 +440,12 @@ export interface RoleEditorDraft {
toolPolicies: Record;
model: string;
thinkingLevel: string;
- activeTab: "prompt" | "tools" | "model";
+ activeTab: "prompt" | "tools";
}
export interface RoleEditorCallbacks {
onDraftChange: (patch: Partial) => void;
- onTabChange: (tab: "prompt" | "tools" | "model") => void;
+ onTabChange: (tab: "prompt" | "tools") => void;
onToggleToolGroup: (group: string) => void;
}
@@ -460,6 +582,20 @@ async function handleScopeChange(scope: string): Promise {
renderApp();
}
+/**
+ * Inline model + thinking control config for list rows. Only the main Roles
+ * page supplies this (enables auto-save); the goal-draft modal omits it so its
+ * embedded list stays list-only by default.
+ */
+export interface RoleRowModelControl {
+ /** Role name currently showing a transient "Saved" flash. */
+ savedFlashRole?: string | null;
+ /** Persist a model choice ("" clears the override) for the row's role. */
+ onModelChange: (role: RoleData, model: string) => void;
+ /** Persist a thinking-level change (override rows only) for the row's role. */
+ onThinkingChange: (role: RoleData, thinkingLevel: string) => void;
+}
+
interface RoleRowOptions {
role: RoleData;
index: number;
@@ -468,11 +604,291 @@ interface RoleRowOptions {
onSelect: (role: RoleData) => void;
onEdit?: (role: RoleData) => void;
onDelete?: (role: RoleData) => void;
+ modelControl?: RoleRowModelControl;
+}
+
+// ----------------------------------------------------------------------------
+// Inline list-row model/thinking source resolution
+//
+// Each list row shows two independent fields (model + thinking) with a compact
+// source badge. When the server attaches field-level `modelResolution`
+// metadata we use it; otherwise we degrade using `origin` / `isInherited` plus
+// the default-pref display heuristic so the row always shows meaningful info.
+// ----------------------------------------------------------------------------
+
+const THINKING_LEVEL_LABELS: Record = {
+ off: "Off", minimal: "Minimal", low: "Low", medium: "Medium", high: "High", xhigh: "Extra high",
+};
+
+type RoleFieldSourceUiKind = "role" | "inherited-role" | "default" | "auto";
+
+interface RoleFieldDisplay {
+ /**
+ * Source kind. `"role"` (current-scope override) is the ONLY kind that feeds
+ * the editable picker and exposes clear/reset; everything else displays the
+ * effective value + badge read-only (findings #1, #2).
+ */
+ kind: RoleFieldSourceUiKind;
+ /** Compact source badge text, e.g. "Role override", "Session default". */
+ badge: string;
+ /** Resolved value text (model id / thinking label) for the source line. */
+ effectiveLabel: string;
+ /**
+ * Raw effective value (model id / thinking level) used ONLY for the
+ * read-only source-line label (incl. inherited-role / default values).
+ *
+ * It is deliberately NOT fed into the editable picker: doing so let the
+ * shared `renderModelRow` clamp an inherited/unsupported thinking value at
+ * render time and auto-persist it as a spurious current-scope override
+ * (review finding #1). The picker is fed only `currentScopeRoleOverrides`.
+ */
+ effectiveValue: string;
+ /**
+ * Hover tooltip explaining the inherited source (e.g. what "session default"
+ * means). Only set for inherited tiers; the override caption needs none.
+ */
+ title?: string;
+}
+
+/**
+ * The model/thinking values that are CURRENT-SCOPE overrides (the layer the
+ * inline list row would write to), as opposed to inherited/default values.
+ *
+ * Used both to derive the row `data-model-state` and to persist inline edits
+ * without accidentally promoting an inherited value into a fresh current-scope
+ * override (e.g. changing only thinking must not bake the inherited model in,
+ * and clearing the model must not wipe a thinking override). Prefers the
+ * server's `modelResolution` (source === "role" ⇒ current editable layer);
+ * falls back to the top-level fields for drafts that carry no metadata.
+ */
+function currentScopeRoleOverrides(role: RoleData): { model: string; thinkingLevel: string } {
+ const meta = role.modelResolution;
+ if (meta) {
+ return {
+ model: meta.model.source === "role" ? (meta.model.value ?? "") : "",
+ thinkingLevel: meta.thinkingLevel.source === "role" ? (meta.thinkingLevel.value ?? "") : "",
+ };
+ }
+ return { model: role.model ?? "", thinkingLevel: role.thinkingLevel ?? "" };
+}
+
+interface RoleModelDisplay {
+ /** Pack-managed roles are read-only inline (manage via the Marketplace). */
+ packReadonly: boolean;
+ packName?: string | null;
+ model: RoleFieldDisplay;
+ thinking: RoleFieldDisplay;
+ state: "override" | "thinking-override" | "inherited" | "readonly";
+}
+
+function roleOriginDetailLabel(origin?: ConfigOrigin, packName?: string | null): string {
+ if (packName) return `pack ${packName}`;
+ switch (origin) {
+ case "project": return "Project";
+ case "server": return "Server";
+ case "builtin": return "Built-in";
+ case "user": return "User";
+ default: return "";
+ }
+}
+
+function thinkingLevelLabel(value: string): string {
+ return THINKING_LEVEL_LABELS[value] ?? value;
+}
+
+function renderRoleDefinitionOriginBadge(origin?: ConfigOrigin, overrides?: ConfigOrigin, originPackName?: string | null): TemplateResult | string {
+ if (!origin) return "";
+ if (!overrides) return renderOriginBadge(origin, undefined, originPackName);
+ return html`
+
+ ${renderOriginBadge(origin, undefined, originPackName)}
+ role definition overrides ${overrides}
+
+ `;
+}
+
+/**
+ * Resolve the model + thinking display (effective value + source badge) for a
+ * list row. Prefers the server's field-level `modelResolution` metadata when
+ * present; otherwise degrades using the default-pref display heuristic. A
+ * present top-level `model` / `thinkingLevel` with no metadata is shown as a
+ * "Role override" — only metadata can attribute a value to an inherited role.
+ */
+function computeRoleModelDisplay(role: RoleData): RoleModelDisplay {
+ const directPackName = (role as any).originPackName as string | null | undefined;
+ const packId = (role as any).originPackId as string | null | undefined;
+ const meta = role.modelResolution;
+ const prefKey = getRoleDefaultModelPrefKey(role.name);
+ const def = getRoleDefaultModel(role.name);
+
+ // The default-tier badge follows the session/reviewer heuristic, downgrading to
+ // "Auto default" only when no default model is configured at all (the tier is
+ // effectively auto-selected). Applied consistently to model + thinking.
+ const autoTier = !def.model;
+ const defaultBadge = autoTier
+ ? "Auto default"
+ : (prefKey === "default.reviewModel" ? "Reviewer default" : "Session default");
+ const defaultKind: RoleFieldSourceUiKind = autoTier ? "auto" : "default";
+ // Hover copy that spells out what each inherited source means, so the inline
+ // "session/reviewer default" labels are never cryptic. Plain text only.
+ const defaultTitle = autoTier
+ ? "No default model is configured for this role, so the gateway auto-selects the best available model. Set a default in Settings → Models."
+ : (prefKey === "default.reviewModel"
+ ? "Reviewer default — the model and thinking effort used by reviewer / QA-kind roles. Change it in Settings → Models."
+ : "Session default — the model and thinking effort used by normal agent sessions. Change it in Settings → Models.");
+ const inheritedRoleTitle = "Inherited from a lower-scope role override. Pick a model here to override it for the current scope.";
+
+ const packName = directPackName ?? meta?.model?.originPackName ?? meta?.thinkingLevel?.originPackName ?? undefined;
+ // Pack-managed roles (or any field the server flags non-editable) are
+ // read-only inline — manage them via the Marketplace.
+ const metaReadonly = !!meta && (meta.model.editable === false || meta.thinkingLevel.editable === false);
+ const packReadonly = !!(directPackName || packId) || metaReadonly;
+
+ const inheritedBadge = (m: RoleFieldSource): string => {
+ const detail = m.sourceLabel || roleOriginDetailLabel(m.origin as ConfigOrigin | undefined, m.originPackName ?? packName);
+ return detail ? `${detail} role override` : "a lower-scope role override";
+ };
+
+ const fromMeta = (m: RoleFieldSource, format: (v: string) => string, emptyLabel: string): RoleFieldDisplay => {
+ const value = m.value ?? "";
+ const effectiveLabel = value ? format(value) : emptyLabel;
+ switch (m.source) {
+ // `effectiveValue` is metadata only; the editable picker is fed
+ // `currentScopeRoleOverrides` so inherited values are never
+ // clamped/auto-saved (finding #1).
+ case "role": return { kind: "role", badge: "Role override", effectiveLabel, effectiveValue: value };
+ case "inherited-role": return { kind: "inherited-role", badge: inheritedBadge(m), effectiveLabel, effectiveValue: value, title: inheritedRoleTitle };
+ default: return { kind: defaultKind, badge: defaultBadge, effectiveLabel, effectiveValue: "", title: defaultTitle };
+ }
+ };
+
+ // MODEL
+ let model: RoleFieldDisplay;
+ if (meta) {
+ model = fromMeta(meta.model, (v) => formatModelPref(v), formatModelPref(def.model));
+ } else if (role.model) {
+ model = { kind: "role", badge: "Role override", effectiveLabel: formatModelPref(role.model), effectiveValue: role.model };
+ } else {
+ model = { kind: defaultKind, badge: defaultBadge, effectiveLabel: formatModelPref(def.model), effectiveValue: "", title: defaultTitle };
+ }
+
+ // THINKING
+ const defThinkingLabel = def.thinking ? thinkingLevelLabel(def.thinking) : "Default";
+ let thinking: RoleFieldDisplay;
+ if (meta) {
+ thinking = fromMeta(meta.thinkingLevel, (v) => thinkingLevelLabel(v), defThinkingLabel);
+ } else if (role.thinkingLevel) {
+ thinking = { kind: "role", badge: "Role override", effectiveLabel: thinkingLevelLabel(role.thinkingLevel), effectiveValue: role.thinkingLevel };
+ } else {
+ thinking = { kind: defaultKind, badge: defaultBadge, effectiveLabel: defThinkingLabel, effectiveValue: "", title: defaultTitle };
+ }
+
+ // State follows the CURRENT-SCOPE override (not the resolved winner): an
+ // inherited-role value must read as "inherited", not "override".
+ const overrides = currentScopeRoleOverrides(role);
+ let state: RoleModelDisplay["state"];
+ if (packReadonly) state = "readonly";
+ else if (overrides.model) state = "override";
+ else if (overrides.thinkingLevel) state = "thinking-override";
+ else state = "inherited";
+
+ return { packReadonly, packName, model, thinking, state };
+}
+
+/**
+ * Inline model/thinking control rendered in the middle of a list row.
+ *
+ * Editable rows reuse the shared `renderModelRow` (model picker + clear/Test +
+ * thinking select) as the interactive line. Inherited/default sources render as
+ * fallback text inside the controls, while overrides are communicated by the
+ * filled value plus reset/Test affordances. Model and thinking are independent:
+ * clearing the model leaves any thinking override in place, and thinking can be
+ * set/cleared on its own. Read-only pack rows render a static effective-value
+ * summary plus the reason.
+ */
+function renderRoleRowModelControl(role: RoleData, control: RoleRowModelControl): TemplateResult {
+ const display = computeRoleModelDisplay(role);
+ const stopRow = (e: Event) => e.stopPropagation();
+
+ if (display.packReadonly) {
+ const reason = display.packName
+ ? html`Managed by pack ${display.packName} — edit it in the Marketplace.`
+ : html`Read-only in this scope.`;
+ return html`
+
+
+
Model${display.model.effectiveLabel}
+
Thinking${display.thinking.effectiveLabel}
+
+
+ ${reason}
+
+
+ `;
+ }
+
+ // Only a CURRENT-SCOPE role override (source === "role") gets the picker's
+ // effective value and the clear/reset affordances. Inherited-role and
+ // default fields feed the picker an empty current-scope override so it never
+ // clamps/auto-saves inherited values, but their source + effective value are
+ // now shown inside the picker fallback itself.
+ const overrides = currentScopeRoleOverrides(role);
+ const inlineFallback = (f: RoleFieldDisplay): string | undefined => {
+ if (f.kind === "role") return undefined;
+ const source = f.kind === "inherited-role" ? f.badge : f.badge.toLowerCase();
+ return `(Uses ${source}: ${f.effectiveLabel})`;
+ };
+ const inlineTitle = (field: "model" | "thinking", f: RoleFieldDisplay): string | undefined => {
+ if (f.kind === "role") return undefined;
+ const source = f.kind === "inherited-role" ? f.badge : f.badge.toLowerCase();
+ return `${field === "model" ? "Model" : "Thinking"} inherits from ${source}: ${f.effectiveLabel}. ${f.title ?? ""}`.trim();
+ };
+
+ return html`
+
+
+ ${renderModelRow(
+ "",
+ "",
+ overrides.model,
+ (v) => control.onModelChange(role, v),
+ overrides.thinkingLevel,
+ (v) => control.onThinkingChange(role, v),
+ "",
+ // Compact list placement: a fixed-width, non-fit-content thinking
+ // Select so its trigger never injects an inline min-width:180px
+ // (the source of the row-control chevron overflow). `onThinkingClear`
+ // puts the thinking reset INSIDE the box next to the model reset, so
+ // both fields reset from the same place (no split-brain UX).
+ {
+ fallbackLabel: inlineFallback(display.model),
+ thinkingFallbackLabel: inlineFallback(display.thinking),
+ thinkingWidth: "clamp(220px, 36%, 360px)",
+ thinkingFitContent: false,
+ onThinkingClear: () => control.onThinkingChange(role, ""),
+ // In the Roles list these resets clear a per-role OVERRIDE
+ // (reverting to the inherited default), so name them precisely
+ // rather than the generic Settings "Reset to auto/default".
+ clearModelTitle: "Reset model override",
+ clearThinkingTitle: "Reset thinking override",
+ modelTitle: inlineTitle("model", display.model),
+ thinkingTitle: inlineTitle("thinking", display.thinking),
+ },
+ )}
+
+
+ `;
}
/** Stateless row renderer; used by both the page list and the goal-draft modal. */
export function renderRoleListRow(opts: RoleRowOptions): TemplateResult {
- const { role, index, selected, customized, onSelect, onEdit, onDelete } = opts;
+ const { role, index, selected, customized, onSelect, onEdit, onDelete, modelControl } = opts;
const origin = (role as any).origin as ConfigOrigin | undefined;
const overrides = (role as any).overrides as ConfigOrigin | undefined;
const inherited = isInherited(origin);
@@ -489,9 +905,13 @@ export function renderRoleListRow(opts: RoleRowOptions): TemplateResult {
@keydown=${(e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onSelect(role); } }}>
${idleBlob(role.accessory ?? "none", 42, index, index)}
`;
@@ -567,6 +988,26 @@ function renderListView(): TemplateResult {
onEdit: (r) => showEdit(r),
onDelete: (r) => handleDeleteFromList(r),
emptyAction,
+ modelControl: {
+ savedFlashRole: savedModelFlashRole,
+ onModelChange: (role, model) => {
+ // Model and thinking are independent. Setting OR clearing the model
+ // preserves any existing thinking override — clearing the model must
+ // never write thinkingLevel:"" (thinking is reset only by its own
+ // dedicated clear control, role-row-thinking-clear-btn). The pending
+ // draft (not the stale row) supplies the preserved thinking value so a
+ // rapid follow-up thinking edit cannot clobber this model choice.
+ queueInlineEdit(role, { model });
+ },
+ onThinkingChange: (role, thinking) => {
+ // Thinking is editable/clearable independently of the model. The pending
+ // draft supplies the preserved current-scope model OVERRIDE so a
+ // thinking-only change never promotes an inherited model into a fresh
+ // current-scope model override — and a model picked moments earlier
+ // (still mid-save) is preserved instead of being overwritten with "".
+ queueInlineEdit(role, { thinkingLevel: thinking });
+ },
+ },
});
if (loading || roles.length === 0) return list;
@@ -788,7 +1229,7 @@ export function renderRoleModelTab(opts: RoleModelTabOptions): TemplateResult {
const thinkingDisplay = thinkingLevel || "(default)";
return html`
- Overrides the global default for sessions running as this role. Leave blank to inherit.
+ Role override — empty inherits the role/default hierarchy (inherited role override, then session/review default).
- Overrides the global default for sessions running as this role. Leave blank to inherit.
+ Role override — empty inherits the role/default hierarchy (inherited role override, then session/review default).