diff --git a/clients/macos/vellum-assistant/Features/Settings/CallSiteOverrideRow.swift b/clients/macos/vellum-assistant/Features/Settings/CallSiteOverrideRow.swift new file mode 100644 index 00000000000..ec74b000105 --- /dev/null +++ b/clients/macos/vellum-assistant/Features/Settings/CallSiteOverrideRow.swift @@ -0,0 +1,279 @@ +import SwiftUI +import VellumAssistantShared + +/// Single editable row in `CallSiteOverridesSheet`. Renders a call site's +/// display name plus a compact summary, an "Override default" toggle, and +/// — when the toggle is ON — provider/model pickers with Save/Reset +/// actions. +/// +/// State ownership: +/// - The `draft` binding is the row's working copy. The parent sheet owns +/// the list of drafts so it can compute "any unsaved changes" for the +/// "Save All" header button. +/// - `original` is the persisted value from the store. It drives the +/// "unsaved changes" pill and toggle defaulting (when the user hasn't +/// touched the row yet). +@MainActor +struct CallSiteOverrideRow: View { + @Binding var draft: CallSiteOverride + let original: CallSiteOverride + let providerIds: [String] + /// The user's currently-selected default provider. Used to seed the + /// override picker when the toggle flips ON so the row starts on the + /// provider the user actually defaults to, not whatever happens to come + /// first in the catalog (which can pin the wrong provider on Save). + let defaultProvider: String + let providerDisplayName: (String) -> String + let availableModels: [String: [String]] + let modelDisplayName: (String, String) -> String + let onSave: () -> Void + let onClear: () -> Void + + /// Local expansion state. Defaults to "expanded when the row already has + /// an override or when the user toggles it on" so a freshly-opened sheet + /// shows configured rows expanded but leaves untouched rows collapsed. + @State private var isExpanded: Bool = false + + // MARK: - Computed State + + /// True when the toggle is in the "Override default" position. Mirrors + /// "draft has any non-nil provider/model/profile". Toggling this off + /// clears the draft locally so Save will write a `null` to the daemon. + private var isOverrideOn: Bool { + draft.hasOverride + } + + /// True when the row's draft differs from what's persisted. Drives the + /// Save button enable state and the parent sheet's "Save All" badge. + private var hasUnsavedChanges: Bool { + draft.provider != original.provider + || draft.model != original.model + || draft.profile != original.profile + } + + /// Validation: when the user has picked a provider but no model yet, + /// Save is blocked. This catches the most common partial-edit state + /// without forcing a model-first ordering. + private var validationError: String? { + let provider = draft.provider ?? "" + let model = draft.model ?? "" + if !provider.isEmpty && model.isEmpty { + return "Pick a model" + } + return nil + } + + private var canSave: Bool { + guard hasUnsavedChanges else { return false } + return validationError == nil + } + + var body: some View { + VStack(alignment: .leading, spacing: VSpacing.sm) { + headerRow + + if isExpanded && isOverrideOn { + editor + } + } + .padding(.vertical, VSpacing.xs) + .onAppear { + // Expand rows that are already configured so the user sees their + // current settings without an extra click. + if original.hasOverride { + isExpanded = true + } + } + } + + // MARK: - Header (title + toggle) + + private var headerRow: some View { + HStack(alignment: .center, spacing: VSpacing.md) { + // Tap target for the title/summary expands the row when an + // override is active. Use a Button so VoiceOver treats it as an + // activation surface. + Button { + if isOverrideOn { + withAnimation(VAnimation.fast) { isExpanded.toggle() } + } + } label: { + VStack(alignment: .leading, spacing: VSpacing.xxs) { + Text(draft.displayName) + .font(VFont.bodyMediumDefault) + .foregroundStyle(VColor.contentDefault) + if !summary.isEmpty { + Text(summary) + .font(VFont.bodySmallDefault) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pointerCursor() + .accessibilityHint(isOverrideOn ? "Expands to edit override" : "") + + if isOverrideOn { + VIconView(isExpanded ? .chevronUp : .chevronDown, size: 12) + .foregroundStyle(VColor.contentTertiary) + .accessibilityHidden(true) + } + + VToggle( + isOn: Binding( + get: { isOverrideOn }, + set: { newValue in + if newValue { + // Switching ON: seed with the user's actual + // default provider so the picker starts where + // the user already operates. Falling back to + // catalog order would silently pin a different + // provider on Save when the catalog's first + // entry isn't the user's default. + if !draft.hasOverride { + let seedProvider: String + if providerIds.contains(defaultProvider) { + seedProvider = defaultProvider + } else { + seedProvider = providerIds.first ?? "anthropic" + } + draft.provider = seedProvider + let firstModel = availableModels[seedProvider]?.first ?? "" + draft.model = firstModel.isEmpty ? nil : firstModel + } + withAnimation(VAnimation.fast) { isExpanded = true } + } else { + // Switching OFF: clear locally so Save will write + // null to the daemon. Don't auto-save here — the + // user still has to confirm via the row's Save + // button or "Save All" in the sheet header. + draft.provider = nil + draft.model = nil + draft.profile = nil + withAnimation(VAnimation.fast) { isExpanded = false } + } + } + ), + interactive: true + ) + .accessibilityLabel("\(draft.displayName) override default") + } + } + + // MARK: - Editor (provider/model pickers + actions) + + private var editor: some View { + VStack(alignment: .leading, spacing: VSpacing.sm) { + providerPicker + modelPicker + + if let error = validationError { + Text(error) + .font(VFont.bodySmallDefault) + .foregroundStyle(VColor.systemNegativeStrong) + } + + HStack(spacing: VSpacing.sm) { + VButton( + label: "Reset to Default", + style: .ghost + ) { + onClear() + } + Spacer(minLength: 0) + VButton( + label: "Save", + style: .primary, + isDisabled: !canSave + ) { + onSave() + } + } + } + .padding(.leading, VSpacing.md) + .padding(.top, VSpacing.xs) + } + + private var providerPicker: some View { + VStack(alignment: .leading, spacing: VSpacing.xs) { + Text("Provider") + .font(VFont.labelDefault) + .foregroundStyle(VColor.contentSecondary) + VDropdown( + placeholder: "Select a provider\u{2026}", + selection: Binding( + get: { draft.provider ?? "" }, + set: { newValue in + let normalized = newValue.isEmpty ? nil : newValue + guard normalized != draft.provider else { return } + draft.provider = normalized + // Reset the model when the provider changes so the + // user doesn't end up saving a model that doesn't + // exist on the new provider. Seed with the new + // provider's first model so Save isn't immediately + // blocked by validation. + if let provider = normalized { + let firstModel = availableModels[provider]?.first ?? "" + draft.model = firstModel.isEmpty ? nil : firstModel + } else { + draft.model = nil + } + } + ), + options: providerIds.map { provider in + (label: providerDisplayName(provider), value: provider) + } + ) + } + } + + private var modelPicker: some View { + VStack(alignment: .leading, spacing: VSpacing.xs) { + Text("Model") + .font(VFont.labelDefault) + .foregroundStyle(VColor.contentSecondary) + let provider = draft.provider ?? "" + let models = availableModels[provider] ?? [] + VDropdown( + placeholder: models.isEmpty ? "Select a provider first" : "Select a model\u{2026}", + selection: Binding( + get: { draft.model ?? "" }, + set: { newValue in + draft.model = newValue.isEmpty ? nil : newValue + } + ), + options: models.map { id in + (label: modelDisplayName(provider, id), value: id) + } + ) + .disabled(provider.isEmpty || models.isEmpty) + } + } + + // MARK: - Summary + + /// Inline subtitle describing the current draft (or "Follows default" + /// when nothing is overridden). Keeps the row scannable when collapsed. + private var summary: String { + if !draft.hasOverride { + return "Follows default" + } + var parts: [String] = [] + if let provider = draft.provider, let model = draft.model { + parts.append("\(providerDisplayName(provider)) \u{00B7} \(modelDisplayName(provider, model))") + } else if let model = draft.model { + parts.append(model) + } else if let provider = draft.provider { + parts.append("Provider: \(providerDisplayName(provider))") + } + if let profile = draft.profile { + parts.append("Profile: \(profile)") + } + if hasUnsavedChanges { + parts.append("Unsaved") + } + return parts.joined(separator: " \u{00B7} ") + } +} diff --git a/clients/macos/vellum-assistant/Features/Settings/CallSiteOverridesSheet.swift b/clients/macos/vellum-assistant/Features/Settings/CallSiteOverridesSheet.swift index 78b98d684db..ec527a6bd98 100644 --- a/clients/macos/vellum-assistant/Features/Settings/CallSiteOverridesSheet.swift +++ b/clients/macos/vellum-assistant/Features/Settings/CallSiteOverridesSheet.swift @@ -1,22 +1,51 @@ import SwiftUI import VellumAssistantShared -/// Read-only sheet listing every call site that has at least one explicit -/// `provider`, `model`, or `profile` override set under `llm.callSites.` -/// in the workspace config. Grouped by `CallSiteDomain`. +/// Editable sheet listing every call site in the catalog, grouped by +/// `CallSiteDomain`. Each row exposes an "Override default" toggle plus +/// provider/model pickers. The sheet header provides batch actions — +/// "Save All" (only visible when any rows have unsaved drafts) and +/// "Reset All" (destructive, behind a confirmation dialog). @MainActor struct CallSiteOverridesSheet: View { @ObservedObject var store: SettingsStore @Binding var isPresented: Bool - /// Catalog entries that currently have at least one explicit override, - /// keyed by domain in catalog order. Domains with no overridden call - /// sites are omitted so the empty-state message renders cleanly when - /// the user has nothing configured. - private var overridesByDomain: [(domain: CallSiteDomain, entries: [CallSiteOverride])] { - let active = store.callSiteOverrides.filter { $0.hasOverride } + /// Working copies keyed by call-site ID. Edits live here until the user + /// hits Save (per-row) or Save All (header). Drafts are seeded from + /// `store.callSiteOverrides` on appear and re-synced when the store + /// changes externally. + @State private var drafts: [String: CallSiteOverride] = [:] + + /// Snapshot of the last persisted value we synced into each draft. Used + /// by `syncDraftsFromStore` to distinguish "user has unsaved edits" + /// (draft != lastSynced) from "store changed externally and we need to + /// pick up the new value" (draft == lastSynced but lastSynced != new + /// persisted). Without this, we would compare the draft to the *new* + /// persisted value and incorrectly flag externally-updated rows as + /// touched, which would let Save All clobber newer daemon-side updates. + @State private var lastSyncedFromStore: [String: CallSiteOverride] = [:] + + /// Shows the destructive confirmation for Reset All. + @State private var showResetAllConfirmation = false + + /// Snapshot of provider IDs and per-provider model IDs at sheet open. + /// Captured once so each row sees the same catalog without each row + /// re-querying the store on every render. + private var providerIds: [String] { store.dynamicProviderIds } + + private var availableModels: [String: [String]] { + var byProvider: [String: [String]] = [:] + for providerId in providerIds { + byProvider[providerId] = store.dynamicProviderModels(providerId).map(\.id) + } + return byProvider + } + + /// Catalog entries grouped by domain in catalog order. + private var entriesByDomain: [(domain: CallSiteDomain, entries: [CallSiteOverride])] { var grouped: [CallSiteDomain: [CallSiteOverride]] = [:] - for entry in active { + for entry in CallSiteCatalog.all { grouped[entry.domain, default: []].append(entry) } return CallSiteDomain.allCases @@ -27,26 +56,59 @@ struct CallSiteOverridesSheet: View { } } + /// True when at least one draft differs from the persisted value. + /// Drives the visibility of the "Save All" header button. + private var hasUnsavedDrafts: Bool { + for (id, draft) in drafts { + guard let original = persistedById[id] else { continue } + if draft.provider != original.provider + || draft.model != original.model + || draft.profile != original.profile { + return true + } + } + return false + } + + /// True when at least one persisted entry has any override set. Drives + /// the visibility of the "Reset All" header button. + private var hasAnyPersistedOverride: Bool { + store.callSiteOverrides.contains { $0.hasOverride } + } + + private var persistedById: [String: CallSiteOverride] { + Dictionary(uniqueKeysWithValues: store.callSiteOverrides.map { ($0.id, $0) }) + } + var body: some View { VStack(spacing: 0) { - // Header: title + subtitle + close button. Built with VStack - // chrome rather than VModal so the inner List can manage its - // own scrolling without nesting inside VModal's ScrollView. header SettingsDivider() - if overridesByDomain.isEmpty { - emptyState - } else { - overridesList - } + overridesList SettingsDivider() footer } - .frame(width: 520, height: 480) + .frame(width: 560, height: 540) .background(VColor.surfaceLift) .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) + .onAppear { syncDraftsFromStore() } + .onChange(of: store.callSiteOverrides) { _, _ in + syncDraftsFromStore() + } + .confirmationDialog( + "Reset all per-task overrides?", + isPresented: $showResetAllConfirmation, + titleVisibility: .visible + ) { + Button("Reset All", role: .destructive) { + resetAll() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Every call site will follow your default provider and model. This cannot be undone.") + } } // MARK: - Header / Footer @@ -57,19 +119,31 @@ struct CallSiteOverridesSheet: View { Text("Per-Task Model Overrides") .font(VFont.titleSmall) .foregroundStyle(VColor.contentDefault) - Text("Tasks listed here use a specific provider or model instead of your default.") + Text("Pick a specific provider or model for individual tasks. Anything left off uses your default.") .font(VFont.bodyMediumDefault) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 0) - VButton( - label: "Close", - iconOnly: VIcon.x.rawValue, - style: .ghost, - tintColor: VColor.contentTertiary - ) { - isPresented = false + HStack(spacing: VSpacing.sm) { + if hasUnsavedDrafts { + VButton(label: "Save All", style: .primary) { + saveAll() + } + } + if hasAnyPersistedOverride { + VButton(label: "Reset All", style: .dangerOutline) { + showResetAllConfirmation = true + } + } + VButton( + label: "Close", + iconOnly: VIcon.x.rawValue, + style: .ghost, + tintColor: VColor.contentTertiary + ) { + isPresented = false + } } } .padding(VSpacing.lg) @@ -85,29 +159,27 @@ struct CallSiteOverridesSheet: View { .padding(VSpacing.lg) } - // MARK: - Empty State - - private var emptyState: some View { - VStack(spacing: VSpacing.sm) { - Spacer(minLength: 0) - Text("No overrides set. All tasks use your default model.") - .font(VFont.bodyMediumDefault) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, VSpacing.lg) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - // MARK: - Overrides List private var overridesList: some View { List { - ForEach(overridesByDomain, id: \.domain.id) { group in + ForEach(entriesByDomain, id: \.domain.id) { group in Section { ForEach(group.entries) { entry in - overrideRow(entry) + CallSiteOverrideRow( + draft: draftBinding(for: entry.id), + original: persistedById[entry.id] ?? entry, + providerIds: providerIds, + defaultProvider: store.selectedInferenceProvider, + providerDisplayName: { store.dynamicProviderDisplayName($0) }, + availableModels: availableModels, + modelDisplayName: { provider, modelId in + let models = store.dynamicProviderModels(provider) + return models.first { $0.id == modelId }?.displayName ?? modelId + }, + onSave: { save(id: entry.id) }, + onClear: { clear(id: entry.id) } + ) } } header: { Text(group.domain.displayName) @@ -118,36 +190,132 @@ struct CallSiteOverridesSheet: View { .frame(maxHeight: .infinity) } - private func overrideRow(_ entry: CallSiteOverride) -> some View { - VStack(alignment: .leading, spacing: VSpacing.xxs) { - Text(entry.displayName) - .font(VFont.bodyMediumDefault) - Text(summary(for: entry)) - .font(VFont.bodySmallDefault) - .foregroundStyle(.secondary) + // MARK: - Draft Management + + /// Pull fresh values from the store for any rows the user has not + /// touched. Preserves in-progress edits — without this, saving one row + /// would clobber unsaved drafts in every other row when the store's + /// optimistic update fires `onChange`. + /// + /// "Untouched" is defined as `draft == lastSyncedFromStore[id]` — the + /// draft still matches the value we last accepted from the store. + /// Comparing against the *new* persisted value would mis-flag external + /// updates as user edits and let Save All overwrite newer daemon-side + /// changes with stale drafts captured at sheet open. + private func syncDraftsFromStore() { + var nextDrafts: [String: CallSiteOverride] = drafts + var nextSynced: [String: CallSiteOverride] = lastSyncedFromStore + for entry in store.callSiteOverrides { + let existingDraft = nextDrafts[entry.id] + let baseline = nextSynced[entry.id] + let untouched: Bool + if let draft = existingDraft, let baseline = baseline { + untouched = draft.provider == baseline.provider + && draft.model == baseline.model + && draft.profile == baseline.profile + } else { + // No baseline yet (first sync) or no draft yet — treat as + // untouched so the row picks up the persisted value. + untouched = true + } + if untouched { + nextDrafts[entry.id] = entry + } + // Always advance the baseline so future external updates are + // detected against the latest persisted value, even when the + // user has unsaved edits we left alone. + nextSynced[entry.id] = entry } - .padding(.vertical, VSpacing.xxs) + drafts = nextDrafts + lastSyncedFromStore = nextSynced } - /// Compose the secondary line for a given override entry. - /// - /// Format precedence: - /// - `provider + model` → `" · "` (e.g. `"Anthropic · claude-haiku-4-5"`). - /// - `model` only → `""`. - /// - `provider` only → `"Provider: "`. - /// - `profile` → `"Profile: "` (appended when paired with provider/model). - private func summary(for entry: CallSiteOverride) -> String { - var parts: [String] = [] - if let provider = entry.provider, let model = entry.model { - parts.append("\(store.dynamicProviderDisplayName(provider)) \u{00B7} \(model)") - } else if let model = entry.model { - parts.append(model) - } else if let provider = entry.provider { - parts.append("Provider: \(store.dynamicProviderDisplayName(provider))") + /// Returns a Binding into the draft cache, falling back to the catalog + /// entry when the cache hasn't been populated yet (e.g. mid-render + /// before `onAppear` fires). + private func draftBinding(for id: String) -> Binding { + Binding( + get: { + self.drafts[id] + ?? self.persistedById[id] + ?? CallSiteCatalog.byId[id] + ?? CallSiteOverride(id: id, displayName: id, domain: .utility) + }, + set: { newValue in + self.drafts[id] = newValue + } + ) + } + + // MARK: - Save / Clear / Reset + + private func save(id: String) { + guard let draft = drafts[id] else { return } + if draft.hasOverride { + store.setCallSiteOverride( + id, + provider: draft.provider, + model: draft.model, + profile: draft.profile + ) + } else { + store.clearCallSiteOverride(id) + } + // The draft is now the new persisted state — bump the baseline so + // any subsequent `onChange` from the store doesn't see a stale + // baseline and re-flag the row as touched. + lastSyncedFromStore[id] = drafts[id] + } + + private func clear(id: String) { + // Clear the local draft so the row collapses immediately, then push + // the null-write to the daemon. The store updates its local cache + // optimistically too, so `syncDraftsFromStore` won't bounce the value. + drafts[id]?.provider = nil + drafts[id]?.model = nil + drafts[id]?.profile = nil + store.clearCallSiteOverride(id) + // Baseline now matches the cleared draft (no override). + lastSyncedFromStore[id] = drafts[id] + } + + private func saveAll() { + // Pass only entries with active overrides — entries the user + // toggled off must be omitted so `setCallSiteOverrides` routes + // them through the entry-level null path that clears every leaf + // (provider, model, profile, plus any maxTokens/effort/etc. that + // may have been set elsewhere). Including a row with all-nil + // fields would emit field-level nulls and leave hidden leaves. + let merged = CallSiteCatalog.all.compactMap { entry -> CallSiteOverride? in + guard let draft = drafts[entry.id], draft.hasOverride else { return nil } + return draft + } + store.setCallSiteOverrides(merged) + // After the batch lands, every draft's baseline is the draft itself + // (the daemon now matches local). Refresh baselines for ALL catalog + // entries — both the ones we sent and the implicitly-cleared ones. + for entry in CallSiteCatalog.all { + if let draft = drafts[entry.id] { + lastSyncedFromStore[entry.id] = draft + } } - if let profile = entry.profile { - parts.append("Profile: \(profile)") + } + + private func resetAll() { + // Reset every catalog entry locally and pass an empty list to the + // store so `setCallSiteOverrides` nulls the entire `callSites.` + // entry on the daemon — clearing not just provider/model/profile + // but also any advanced leaves (maxTokens, effort, temperature, + // contextWindow) that may have been set via manual config edits. + for entry in CallSiteCatalog.all { + let cleared = CallSiteOverride( + id: entry.id, + displayName: entry.displayName, + domain: entry.domain + ) + drafts[entry.id] = cleared + lastSyncedFromStore[entry.id] = cleared } - return parts.joined(separator: " \u{00B7} ") + store.setCallSiteOverrides([]) } }