diff --git a/clients/macos/vellum-assistant/App/AppServices.swift b/clients/macos/vellum-assistant/App/AppServices.swift index 9cac9c8123c..0fd5de65c8a 100644 --- a/clients/macos/vellum-assistant/App/AppServices.swift +++ b/clients/macos/vellum-assistant/App/AppServices.swift @@ -11,7 +11,7 @@ public final class AppServices { let secretPromptManager = SecretPromptManager() let zoomManager = ZoomManager() - /// Shared settings state consumed by both SettingsView and SettingsPanel. + /// Shared settings state consumed by SettingsPanel and its tab views. /// Lazy because it needs `ambientAgent` and `daemonClient` which are set above. public lazy var settingsStore: SettingsStore = SettingsStore( daemonClient: daemonClient diff --git a/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift b/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift index adc539d53fc..76d1b7902eb 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/Panels/SettingsPanel.swift @@ -27,12 +27,6 @@ struct SettingsPanel: View { @State private var showingReminders = false @State private var twitterClientId: String = "" @State private var twitterClientSecret: String = "" - @State private var telegramBotTokenText: String = "" - @State private var twilioAccountSidText: String = "" - @State private var twilioAuthTokenText: String = "" - @State private var twilioPhoneNumberText: String = "" - @State private var twilioAreaCodeText: String = "" - @State private var twilioCountryText: String = "US" @State private var ingressUrlText: String = "" @FocusState private var isIngressUrlFocused: Bool @State private var checkingGateway: Bool = false @@ -194,7 +188,7 @@ struct SettingsPanel: View { case .integrations: integrationsContent case .channels: - channelsContent + SettingsChannelsTab(store: store) case .trust: trustContent case .reminders: @@ -918,383 +912,6 @@ struct SettingsPanel: View { .vCard(background: VColor.surfaceSubtle) } - // MARK: - Channels Tab - - private var channelsContent: some View { - VStack(alignment: .leading, spacing: VSpacing.xl) { - // TELEGRAM channel card - VStack(alignment: .leading, spacing: VSpacing.md) { - Text("Telegram") - .font(VFont.sectionTitle) - .foregroundColor(VColor.textPrimary) - - if store.telegramHasBotToken { - HStack(spacing: VSpacing.sm) { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(VColor.success) - .font(.system(size: 14)) - if let username = store.telegramBotUsername { - Text("@\(username)") - .font(VFont.body) - .foregroundColor(VColor.textSecondary) - } else { - Text("Bot token configured") - .font(VFont.body) - .foregroundColor(VColor.textSecondary) - } - Spacer() - VButton(label: "Clear", style: .danger) { - store.clearTelegramCredentials() - telegramBotTokenText = "" - } - } - } else { - HStack(spacing: VSpacing.xs) { - Text("Enter Bot Token") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - } - - SecureField("Telegram bot token", text: $telegramBotTokenText) - .textFieldStyle(.plain) - .font(VFont.body) - .foregroundColor(VColor.textPrimary) - .padding(VSpacing.md) - .background(VColor.surface) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) - ) - - Text("Get your bot token from @BotFather on Telegram") - .font(VFont.caption) - .foregroundColor(VColor.textMuted) - - if store.telegramSaveInProgress { - HStack(spacing: VSpacing.sm) { - ProgressView() - .controlSize(.small) - Text("Saving...") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - } - } else { - VButton(label: "Save", style: .primary) { - store.saveTelegramToken(botToken: telegramBotTokenText) - telegramBotTokenText = "" - } - .disabled(telegramBotTokenText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - - if let error = store.telegramError { - Text(error) - .font(VFont.caption) - .foregroundColor(VColor.error) - } - - if store.telegramHasBotToken { - Divider() - .background(VColor.surfaceBorder) - - guardianSection(channel: "telegram") - } - } - .padding(VSpacing.lg) - .vCard(background: VColor.surfaceSubtle) - - // SMS (TWILIO) channel card - VStack(alignment: .leading, spacing: VSpacing.md) { - Text("SMS (Twilio)") - .font(VFont.sectionTitle) - .foregroundColor(VColor.textPrimary) - - if store.twilioHasCredentials { - HStack(spacing: VSpacing.sm) { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(VColor.success) - .font(.system(size: 14)) - Text("Credentials configured") - .font(VFont.body) - .foregroundColor(VColor.textSecondary) - Spacer() - if store.twilioSaveInProgress { - ProgressView() - .controlSize(.small) - } else { - VButton(label: "Clear Credentials", style: .danger) { - store.clearTwilioCredentials() - } - } - } - } else { - HStack(spacing: VSpacing.xs) { - Text("Enter Account SID and Auth Token") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - } - - TextField("Account SID", text: $twilioAccountSidText) - .textFieldStyle(.plain) - .font(VFont.body) - .foregroundColor(VColor.textPrimary) - .padding(VSpacing.md) - .background(VColor.surface) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) - ) - - SecureField("Auth Token", text: $twilioAuthTokenText) - .textFieldStyle(.plain) - .font(VFont.body) - .foregroundColor(VColor.textPrimary) - .padding(VSpacing.md) - .background(VColor.surface) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) - ) - - if store.twilioSaveInProgress { - HStack(spacing: VSpacing.sm) { - ProgressView() - .controlSize(.small) - Text("Saving...") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - } - } else { - VButton(label: "Save Credentials", style: .primary) { - store.saveTwilioCredentials( - accountSid: twilioAccountSidText, - authToken: twilioAuthTokenText - ) - twilioAuthTokenText = "" - } - .disabled( - twilioAccountSidText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - twilioAuthTokenText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ) - } - } - - Divider() - .background(VColor.surfaceBorder) - - HStack { - Text("Assigned Number") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - Spacer() - Text(store.twilioPhoneNumber ?? "Not assigned") - .font(VFont.mono) - .foregroundColor(store.twilioPhoneNumber == nil ? VColor.textMuted : VColor.textPrimary) - } - - HStack(spacing: VSpacing.sm) { - TextField("Assign existing (+1555...)", text: $twilioPhoneNumberText) - .textFieldStyle(.plain) - .font(VFont.body) - .foregroundColor(VColor.textPrimary) - .padding(VSpacing.md) - .background(VColor.surface) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) - ) - - VButton(label: "Assign", style: .secondary) { - store.assignTwilioNumber(phoneNumber: twilioPhoneNumberText) - twilioPhoneNumberText = "" - } - .disabled( - twilioPhoneNumberText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - store.twilioSaveInProgress - ) - } - - HStack(spacing: VSpacing.sm) { - TextField("Area code (optional)", text: $twilioAreaCodeText) - .textFieldStyle(.plain) - .font(VFont.body) - .foregroundColor(VColor.textPrimary) - .padding(VSpacing.md) - .background(VColor.surface) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) - ) - - TextField("Country", text: $twilioCountryText) - .textFieldStyle(.plain) - .font(VFont.body) - .foregroundColor(VColor.textPrimary) - .padding(VSpacing.md) - .frame(width: 90) - .background(VColor.surface) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) - ) - - VButton(label: "Provision", style: .secondary) { - store.provisionTwilioNumber( - areaCode: twilioAreaCodeText, - country: twilioCountryText - ) - } - .disabled(store.twilioSaveInProgress) - } - - HStack(spacing: VSpacing.sm) { - if store.twilioListInProgress { - ProgressView() - .controlSize(.small) - } - VButton(label: "Refresh Numbers", style: .tertiary) { - store.refreshTwilioNumbers() - } - .disabled(store.twilioListInProgress) - } - - if !store.twilioNumbers.isEmpty { - ForEach(store.twilioNumbers, id: \.phoneNumber) { number in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(number.phoneNumber) - .font(VFont.mono) - .foregroundColor(VColor.textPrimary) - Text(number.friendlyName) - .font(VFont.caption) - .foregroundColor(VColor.textMuted) - } - Spacer() - VButton(label: "Use", style: .secondary) { - store.assignTwilioNumber(phoneNumber: number.phoneNumber) - } - .disabled(store.twilioSaveInProgress) - } - } - } - - if let warning = store.twilioWarning { - Text(warning) - .font(VFont.caption) - .foregroundColor(VColor.warning) - } - - if let error = store.twilioError { - Text(error) - .font(VFont.caption) - .foregroundColor(VColor.error) - } - - if store.twilioHasCredentials { - Divider() - .background(VColor.surfaceBorder) - - guardianSection(channel: "sms") - } - } - .padding(VSpacing.lg) - .vCard(background: VColor.surfaceSubtle) - } - .onAppear { - store.refreshChannelGuardianStatus(channel: "telegram") - store.refreshChannelGuardianStatus(channel: "sms") - } - } - - // MARK: - Guardian Verification Section - - @ViewBuilder - private func guardianSection(channel: String) -> some View { - let identity: String? = channel == "telegram" ? store.telegramGuardianIdentity : store.smsGuardianIdentity - let verified: Bool = channel == "telegram" ? store.telegramGuardianVerified : store.smsGuardianVerified - let inProgress: Bool = channel == "telegram" ? store.telegramGuardianVerificationInProgress : store.smsGuardianVerificationInProgress - let instruction: String? = channel == "telegram" ? store.telegramGuardianInstruction : store.smsGuardianInstruction - let error: String? = channel == "telegram" ? store.telegramGuardianError : store.smsGuardianError - - VStack(alignment: .leading, spacing: VSpacing.sm) { - Text("Guardian Verification") - .font(VFont.bodyMedium) - .foregroundColor(VColor.textPrimary) - - if verified { - HStack(spacing: VSpacing.sm) { - Image(systemName: "checkmark.shield.fill") - .foregroundColor(VColor.success) - .font(.system(size: 14)) - if let identity { - Text("Verified: \(identity)") - .font(VFont.body) - .foregroundColor(VColor.textSecondary) - } else { - Text("Verified") - .font(VFont.body) - .foregroundColor(VColor.textSecondary) - } - Spacer() - VButton(label: "Revoke", style: .danger) { - store.revokeChannelGuardian(channel: channel) - } - } - } else if inProgress { - VStack(alignment: .leading, spacing: VSpacing.xs) { - HStack(spacing: VSpacing.sm) { - ProgressView() - .controlSize(.small) - Text("Generating verification code...") - .font(VFont.caption) - .foregroundColor(VColor.textSecondary) - } - Text("You will get a code to send as /guardian_verify from your \(channel == "telegram" ? "Telegram account" : "SMS number").") - .font(VFont.caption) - .foregroundColor(VColor.textMuted) - } - } else if let instruction { - Text(instruction) - .font(VFont.mono) - .foregroundColor(VColor.textPrimary) - .padding(VSpacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .background(VColor.surface) - .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) - ) - .textSelection(.enabled) - } else { - HStack(spacing: VSpacing.sm) { - Image(systemName: "shield.slash") - .foregroundColor(VColor.textMuted) - .font(.system(size: 14)) - Text("Not verified") - .font(VFont.body) - .foregroundColor(VColor.textMuted) - Spacer() - VButton(label: "Verify Guardian", style: .secondary) { - store.startChannelGuardianVerification(channel: channel) - } - } - } - - if let error { - Text(error) - .font(VFont.caption) - .foregroundColor(VColor.error) - } - } - } - // MARK: - Trust Tab private var trustContent: some View { diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsChannelsTab.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsChannelsTab.swift new file mode 100644 index 00000000000..efa7787d33c --- /dev/null +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsChannelsTab.swift @@ -0,0 +1,477 @@ +import SwiftUI + +/// Channels settings tab — Telegram and SMS (Twilio) channel configuration. +/// Displays a compact, status-first layout optimized for viewing and light +/// reconfiguration. Initial setup is handled conversationally by the assistant. +@MainActor +struct SettingsChannelsTab: View { + @ObservedObject var store: SettingsStore + + // Telegram credential entry + @State private var telegramBotTokenText = "" + @State private var telegramSetupExpanded = false + + // Twilio credential entry + @State private var twilioAccountSidText = "" + @State private var twilioAuthTokenText = "" + @State private var twilioSetupExpanded = false + + // Twilio number picker + @State private var twilioNumberPickerExpanded = false + + var body: some View { + VStack(alignment: .leading, spacing: VSpacing.xl) { + telegramCard + twilioCard + } + .onAppear { + store.refreshChannelGuardianStatus(channel: "telegram") + store.refreshChannelGuardianStatus(channel: "sms") + } + } + + // MARK: - Telegram Channel Card + + private var telegramCard: some View { + VStack(alignment: .leading, spacing: VSpacing.md) { + Text("Telegram") + .font(VFont.sectionTitle) + .foregroundColor(VColor.textPrimary) + + // Bot credential row + if store.telegramHasBotToken { + channelStatusRow( + label: "Bot", + icon: "checkmark.circle.fill", + iconColor: VColor.success, + value: store.telegramBotUsername.map { "@\($0)" } ?? "Configured", + action: .init(label: "Clear", style: .danger, disabled: store.telegramSaveInProgress) { + store.clearTelegramCredentials() + telegramBotTokenText = "" + telegramSetupExpanded = false + } + ) + } else if telegramSetupExpanded { + telegramCredentialEntry + } else { + channelStatusRow( + label: "Bot", + icon: "exclamationmark.triangle", + iconColor: VColor.warning, + value: "Not configured", + valueColor: VColor.textMuted, + action: .init(label: "Set Up", style: .secondary) { + telegramSetupExpanded = true + } + ) + } + + if let error = store.telegramError { + Text(error) + .font(VFont.caption) + .foregroundColor(VColor.error) + } + + // Guardian row (only when credentials exist) + if store.telegramHasBotToken { + Divider().background(VColor.surfaceBorder) + guardianStatusRow(channel: "telegram") + } + } + .padding(VSpacing.lg) + .vCard(background: VColor.surfaceSubtle) + } + + // MARK: - Telegram Credential Entry + + private var telegramCredentialEntry: some View { + VStack(alignment: .leading, spacing: VSpacing.sm) { + HStack { + Text("Bot Token") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + Spacer() + VButton(label: "Cancel", style: .tertiary) { + telegramSetupExpanded = false + telegramBotTokenText = "" + } + } + + SecureField("Telegram bot token", text: $telegramBotTokenText) + .textFieldStyle(.plain) + .font(VFont.body) + .foregroundColor(VColor.textPrimary) + .padding(VSpacing.md) + .background(VColor.surface) + .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.md) + .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) + ) + + Text("Get your bot token from @BotFather on Telegram") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + + if store.telegramSaveInProgress { + HStack(spacing: VSpacing.sm) { + ProgressView() + .controlSize(.small) + Text("Saving...") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + } else { + VButton(label: "Save", style: .primary) { + store.saveTelegramToken(botToken: telegramBotTokenText) + telegramBotTokenText = "" + telegramSetupExpanded = false + } + .disabled(telegramBotTokenText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + + // MARK: - SMS (Twilio) Channel Card + + private var twilioCard: some View { + VStack(alignment: .leading, spacing: VSpacing.md) { + Text("SMS (Twilio)") + .font(VFont.sectionTitle) + .foregroundColor(VColor.textPrimary) + + // Credentials row + if store.twilioHasCredentials { + channelStatusRow( + label: "Credentials", + icon: "checkmark.circle.fill", + iconColor: VColor.success, + value: "Configured", + action: .init(label: "Clear", style: .danger, disabled: store.twilioSaveInProgress) { + store.clearTwilioCredentials() + twilioSetupExpanded = false + } + ) + } else if twilioSetupExpanded { + twilioCredentialEntry + } else { + channelStatusRow( + label: "Credentials", + icon: "exclamationmark.triangle", + iconColor: VColor.warning, + value: "Not configured", + valueColor: VColor.textMuted, + action: .init(label: "Set Up", style: .secondary) { + twilioSetupExpanded = true + } + ) + } + + // Phone number row (only when credentials exist) + if store.twilioHasCredentials { + Divider().background(VColor.surfaceBorder) + + if twilioNumberPickerExpanded { + twilioNumberPicker + } else { + channelStatusRow( + label: "Phone Number", + icon: store.twilioPhoneNumber != nil ? "phone.fill" : "phone", + iconColor: store.twilioPhoneNumber != nil ? VColor.success : VColor.textMuted, + value: store.twilioPhoneNumber ?? "Not assigned", + valueFont: VFont.mono, + valueColor: store.twilioPhoneNumber != nil ? VColor.textPrimary : VColor.textMuted, + action: .init(label: "Change", style: .secondary) { + twilioNumberPickerExpanded = true + if !store.twilioListInProgress { + store.refreshTwilioNumbers() + } + } + ) + } + } + + if let warning = store.twilioWarning { + Text(warning) + .font(VFont.caption) + .foregroundColor(VColor.warning) + } + + if let error = store.twilioError { + Text(error) + .font(VFont.caption) + .foregroundColor(VColor.error) + } + + // Guardian row (only when credentials exist) + if store.twilioHasCredentials { + Divider().background(VColor.surfaceBorder) + guardianStatusRow(channel: "sms") + } + } + .padding(VSpacing.lg) + .vCard(background: VColor.surfaceSubtle) + } + + // MARK: - Twilio Credential Entry + + private var twilioCredentialEntry: some View { + VStack(alignment: .leading, spacing: VSpacing.sm) { + HStack { + Text("Account SID and Auth Token") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + Spacer() + VButton(label: "Cancel", style: .tertiary) { + twilioSetupExpanded = false + twilioAccountSidText = "" + twilioAuthTokenText = "" + } + } + + TextField("Account SID", text: $twilioAccountSidText) + .textFieldStyle(.plain) + .font(VFont.body) + .foregroundColor(VColor.textPrimary) + .padding(VSpacing.md) + .background(VColor.surface) + .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.md) + .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) + ) + + SecureField("Auth Token", text: $twilioAuthTokenText) + .textFieldStyle(.plain) + .font(VFont.body) + .foregroundColor(VColor.textPrimary) + .padding(VSpacing.md) + .background(VColor.surface) + .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.md) + .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) + ) + + if store.twilioSaveInProgress { + HStack(spacing: VSpacing.sm) { + ProgressView() + .controlSize(.small) + Text("Saving...") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + } else { + VButton(label: "Save Credentials", style: .primary) { + store.saveTwilioCredentials( + accountSid: twilioAccountSidText, + authToken: twilioAuthTokenText + ) + twilioAuthTokenText = "" + twilioSetupExpanded = false + } + .disabled( + twilioAccountSidText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + twilioAuthTokenText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ) + } + } + } + + // MARK: - Twilio Number Picker + + private var twilioNumberPicker: some View { + VStack(alignment: .leading, spacing: VSpacing.sm) { + HStack { + Text("Phone Number") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + Spacer() + VButton(label: "Cancel", style: .tertiary) { + twilioNumberPickerExpanded = false + } + } + + if store.twilioListInProgress { + HStack(spacing: VSpacing.sm) { + ProgressView() + .controlSize(.small) + Text("Loading numbers...") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + } else if store.twilioNumbers.isEmpty { + Text("No phone numbers found on this Twilio account.") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + } else { + ForEach(store.twilioNumbers, id: \.phoneNumber) { number in + let isCurrent = number.phoneNumber == store.twilioPhoneNumber + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(number.phoneNumber) + .font(VFont.mono) + .foregroundColor(VColor.textPrimary) + Text(number.friendlyName) + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + } + Spacer() + if isCurrent { + HStack(spacing: VSpacing.xs) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(VColor.success) + .font(.system(size: 12)) + Text("Current") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + } else { + VButton(label: "Use", style: .secondary) { + store.assignTwilioNumber(phoneNumber: number.phoneNumber) + twilioNumberPickerExpanded = false + } + .disabled(store.twilioSaveInProgress) + } + } + } + } + } + } + + // MARK: - Shared Status Row + + private struct RowAction { + let label: String + let style: VButton.Style + var disabled: Bool = false + let action: () -> Void + } + + @ViewBuilder + private func channelStatusRow( + label: String, + icon: String, + iconColor: Color, + value: String, + valueFont: Font = VFont.body, + valueColor: Color = VColor.textSecondary, + action: RowAction? = nil + ) -> some View { + HStack(spacing: VSpacing.sm) { + Text(label) + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + .frame(width: 90, alignment: .leading) + + Image(systemName: icon) + .foregroundColor(iconColor) + .font(.system(size: 12)) + + Text(value) + .font(valueFont) + .foregroundColor(valueColor) + .lineLimit(1) + + Spacer() + + if let action { + VButton(label: action.label, style: action.style, action: action.action) + .disabled(action.disabled) + } + } + } + + // MARK: - Guardian Verification Row + + @ViewBuilder + private func guardianStatusRow(channel: String) -> some View { + let identity: String? = channel == "telegram" ? store.telegramGuardianIdentity : store.smsGuardianIdentity + let verified: Bool = channel == "telegram" ? store.telegramGuardianVerified : store.smsGuardianVerified + let inProgress: Bool = channel == "telegram" ? store.telegramGuardianVerificationInProgress : store.smsGuardianVerificationInProgress + let instruction: String? = channel == "telegram" ? store.telegramGuardianInstruction : store.smsGuardianInstruction + let error: String? = channel == "telegram" ? store.telegramGuardianError : store.smsGuardianError + + VStack(alignment: .leading, spacing: VSpacing.sm) { + if verified { + channelStatusRow( + label: "Guardian", + icon: "checkmark.shield.fill", + iconColor: VColor.success, + value: identity.map { "Verified: \($0)" } ?? "Verified", + action: .init(label: "Revoke", style: .danger) { + store.revokeChannelGuardian(channel: channel) + } + ) + } else if inProgress { + HStack(spacing: VSpacing.sm) { + Text("Guardian") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + .frame(width: 90, alignment: .leading) + ProgressView() + .controlSize(.small) + Text("Generating verification code...") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + } + Text("You will get a code to send as /guardian_verify from your \(channel == "telegram" ? "Telegram account" : "SMS number").") + .font(VFont.caption) + .foregroundColor(VColor.textMuted) + .padding(.leading, 90 + VSpacing.sm) + } else if let instruction { + HStack(spacing: VSpacing.sm) { + Text("Guardian") + .font(VFont.caption) + .foregroundColor(VColor.textSecondary) + .frame(width: 90, alignment: .leading) + Text(instruction) + .font(VFont.mono) + .foregroundColor(VColor.textPrimary) + .padding(VSpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(VColor.surface) + .clipShape(RoundedRectangle(cornerRadius: VRadius.md)) + .overlay( + RoundedRectangle(cornerRadius: VRadius.md) + .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) + ) + .textSelection(.enabled) + } + } else { + channelStatusRow( + label: "Guardian", + icon: "shield.slash", + iconColor: VColor.textMuted, + value: "Not verified", + valueColor: VColor.textMuted, + action: .init(label: "Verify", style: .secondary) { + store.startChannelGuardianVerification(channel: channel) + } + ) + } + + if let error { + Text(error) + .font(VFont.caption) + .foregroundColor(VColor.error) + .padding(.leading, 90 + VSpacing.sm) + } + } + } +} + +// MARK: - Preview + +struct SettingsChannelsTab_Previews: PreviewProvider { + static var previews: some View { + ZStack { + VColor.background.ignoresSafeArea() + ScrollView { + SettingsChannelsTab(store: SettingsStore()) + .padding(VSpacing.lg) + } + } + .frame(width: 500, height: 600) + } +} diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift index 474b0087be7..d98eae4b19a 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift @@ -5,8 +5,8 @@ import VellumAssistantShared private let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.vellum.vellum-assistant", category: "SettingsStore") -/// Single source of truth for settings state shared between `SettingsView` -/// (standalone window) and `SettingsPanel` (main window side panel). +/// Single source of truth for settings state shared between `SettingsPanel` +/// (main window side panel) and its extracted tab views. @MainActor public final class SettingsStore: ObservableObject { // MARK: - API Key State @@ -421,8 +421,8 @@ public final class SettingsStore: ObservableObject { refreshChannelGuardianStatus(channel: "telegram") refreshChannelGuardianStatus(channel: "sms") - // Ingress config is refreshed by onAppear in SettingsPanel / - // SettingsView, not here, to avoid duplicate get requests whose + // Ingress config is refreshed by onAppear in SettingsPanel, + // not here, to avoid duplicate get requests whose // stale responses could overwrite an optimistic toggle. } @@ -996,7 +996,7 @@ public final class SettingsStore: ObservableObject { func saveIngressPublicBaseUrl(_ raw: String) { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) // Update local state optimistically so the focus-leave onChange handler - // in SettingsPanel/SettingsView reads the new value instead of reverting + // in SettingsPanel reads the new value instead of reverting // the text field to a stale URL. The daemon's success response // (handled by onIngressConfigResponse) will confirm or correct. let previous = ingressPublicBaseUrl diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsView.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsView.swift deleted file mode 100644 index 92199e0e2e6..00000000000 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsView.swift +++ /dev/null @@ -1,1192 +0,0 @@ -import Combine -import SwiftUI -import VellumAssistantShared - -public struct SettingsView: View { - @ObservedObject var store: SettingsStore - @State private var apiKeyText = "" - @State private var braveKeyText = "" - @State private var perplexityKeyText = "" - @State private var imageGenKeyText = "" - @State private var openaiKeyText = "" - @State private var vercelKeyText = "" - @State private var twitterClientId = "" - @State private var twitterClientSecret = "" - @State private var telegramBotTokenText = "" - @State private var twilioAccountSidText = "" - @State private var twilioAuthTokenText = "" - @State private var twilioPhoneNumberText = "" - @State private var twilioAreaCodeText = "" - @State private var twilioCountryText = "US" - @State private var ingressUrlText = "" - @FocusState private var isIngressUrlFocused: Bool - @State private var accessibilityGranted = false - @State private var screenRecordingGranted = false - @State private var showingPrivacy = false - @State private var showingSkills = false - @State private var showingTrustRules = false - @State private var newAllowlistDomain = "" - #if DEBUG - @State private var showingEnvVars = false - @State private var appEnvVars: [(String, String)] = [] - @State private var daemonEnvVars: [(String, String)] = [] - #endif - @State private var skillsViewModel: SkillsSettingsViewModel? - @State private var activationKey: ActivationKey = { - let stored = UserDefaults.standard.string(forKey: "activationKey") ?? "fn" - return ActivationKey(rawValue: stored) ?? .fn - }() - var daemonClient: DaemonClient? - - public init(store: SettingsStore, daemonClient: DaemonClient? = nil) { - self.store = store - self.daemonClient = daemonClient - } - - // Re-check permissions every 2 seconds while the window is open - private let permissionTimer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() - - public var body: some View { - Form { - Section("Anthropic API Key") { - if store.hasKey { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 14)) - Text(store.maskedKey) - .foregroundStyle(.secondary) - Spacer() - Button("Clear") { - store.clearAPIKey() - apiKeyText = "" - } - .tint(.red) - } - } else { - SecureField("Enter API key", text: $apiKeyText) - .textFieldStyle(VInputStyle()) - HStack { - Text("Get your API key at console.anthropic.com") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Save") { - store.saveAPIKey(apiKeyText) - apiKeyText = "" - } - .disabled(apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - - if store.hasKey { - Section("Model") { - Picker("Active model", selection: $store.selectedModel) { - ForEach(SettingsStore.availableModels, id: \.self) { model in - Text(SettingsStore.modelDisplayNames[model] ?? model) - .tag(model) - } - } - .onChange(of: store.selectedModel) { _, newValue in - store.setModel(newValue) - } - } - } - - Section("Perplexity API Key") { - if store.hasPerplexityKey { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 14)) - Text(store.maskedPerplexityKey) - .foregroundStyle(.secondary) - Spacer() - Button("Clear") { - store.clearPerplexityKey() - perplexityKeyText = "" - } - .tint(.red) - } - } else { - SecureField("Enter Perplexity API key", text: $perplexityKeyText) - .textFieldStyle(VInputStyle()) - HStack { - Text("Get your API key at perplexity.ai/settings/api") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Save") { - store.savePerplexityKey(perplexityKeyText) - perplexityKeyText = "" - } - .disabled(perplexityKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - - Section("Brave Search API Key") { - if store.hasBraveKey { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 14)) - Text(store.maskedBraveKey) - .foregroundStyle(.secondary) - Spacer() - Button("Clear") { - store.clearBraveKey() - braveKeyText = "" - } - .tint(.red) - } - } else { - SecureField("Enter Brave Search API key", text: $braveKeyText) - .textFieldStyle(VInputStyle()) - HStack { - Text("Get your API key at brave.com/search/api") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Save") { - store.saveBraveKey(braveKeyText) - braveKeyText = "" - } - .disabled(braveKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - - Section("Image Generation") { - if store.hasImageGenKey { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 14)) - Text(store.maskedImageGenKey) - .foregroundStyle(.secondary) - Spacer() - Button("Clear") { - store.clearImageGenKey() - imageGenKeyText = "" - } - .tint(.red) - } - - Picker("Model", selection: $store.selectedImageGenModel) { - ForEach(SettingsStore.availableImageGenModels, id: \.self) { model in - Text(SettingsStore.imageGenModelDisplayNames[model] ?? model) - .tag(model) - } - } - .onChange(of: store.selectedImageGenModel) { _, newValue in - store.setImageGenModel(newValue) - } - } else { - SecureField("Enter Gemini API key", text: $imageGenKeyText) - .textFieldStyle(VInputStyle()) - HStack { - Text("Get your API key at aistudio.google.com/apikey") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Save") { - store.saveImageGenKey(imageGenKeyText) - imageGenKeyText = "" - } - .disabled(imageGenKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - - Section("OpenAI API Key") { - if store.hasOpenAIKey { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 14)) - Text(store.maskedOpenAIKey) - .foregroundStyle(.secondary) - Spacer() - Button("Clear") { - store.clearOpenAIKey() - openaiKeyText = "" - } - .tint(.red) - } - } else { - SecureField("Enter OpenAI API key", text: $openaiKeyText) - .textFieldStyle(VInputStyle()) - HStack { - Text("Get your API key at platform.openai.com/api-keys") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Save") { - store.saveOpenAIKey(openaiKeyText) - openaiKeyText = "" - } - .disabled(openaiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - - Section("Vercel API Key") { - if store.hasVercelKey { - HStack { - Text("Token configured") - .foregroundStyle(.secondary) - Spacer() - Button("Clear") { - store.clearVercelKey() - vercelKeyText = "" - } - .tint(.red) - } - } else { - SecureField("Enter Vercel API token", text: $vercelKeyText) - .textFieldStyle(VInputStyle()) - HStack { - Text("Get your API token at vercel.com/account/tokens") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Save") { - store.saveVercelKey(vercelKeyText) - } - .disabled(vercelKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - - Section("Twitter / X") { - Picker("Integration mode", selection: $store.twitterMode) { - Text("Local (BYO App)").tag("local_byo") - Text("Managed").tag("managed") - } - .pickerStyle(.segmented) - .onChange(of: store.twitterMode) { _, newValue in - store.setTwitterMode(newValue) - } - - if store.twitterMode == "managed" { - HStack(spacing: 6) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) - Text("Managed mode is coming soon. Switch to Local (BYO App) to connect now.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - - if store.twitterMode == "local_byo" { - if !store.twitterLocalClientConfigured { - VStack(alignment: .leading, spacing: 6) { - TextField("OAuth Client ID", text: $twitterClientId) - .textFieldStyle(VInputStyle()) - SecureField("OAuth Client Secret (optional)", text: $twitterClientSecret) - .textFieldStyle(VInputStyle()) - HStack { - Text("Create an app at developer.x.com") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - Button("Save") { - store.saveTwitterLocalClient( - clientId: twitterClientId, - clientSecret: twitterClientSecret.isEmpty ? nil : twitterClientSecret - ) - twitterClientId = "" - twitterClientSecret = "" - } - .disabled(twitterClientId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } else { - if store.twitterConnected { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 14)) - Text("Connected") - .foregroundStyle(.secondary) - if let account = store.twitterAccountInfo { - Text(account) - .font(.caption) - .foregroundStyle(.tertiary) - } - Spacer() - Button("Disconnect") { - store.disconnectTwitter() - } - .tint(.red) - } - } else { - HStack(spacing: 6) { - Image(systemName: "circle") - .foregroundStyle(.tertiary) - .font(.system(size: 14)) - Text("App configured") - .foregroundStyle(.secondary) - Spacer() - if store.twitterAuthInProgress { - ProgressView() - .controlSize(.small) - Text("Connecting...") - .font(.caption) - .foregroundStyle(.secondary) - } else { - Button("Connect") { - store.connectTwitter() - } - } - } - } - - if let error = store.twitterAuthError { - Text(error) - .font(.caption) - .foregroundColor(.red) - } - - HStack { - Spacer() - Button("Clear App Config") { - store.clearTwitterLocalClient() - twitterClientId = "" - twitterClientSecret = "" - } - .font(.caption) - .foregroundStyle(.tertiary) - } - } - } - } - - // MARK: - Channels - Section("Telegram Channel") { - if store.telegramHasBotToken { - HStack { - Image(systemName: "checkmark.circle.fill").foregroundColor(.green) - if let username = store.telegramBotUsername { - Text("@\(username)") - } else { - Text("Bot token configured") - } - Spacer() - Button("Clear") { - store.clearTelegramCredentials() - telegramBotTokenText = "" - } - } - } else { - SecureField("Enter bot token", text: $telegramBotTokenText) - .textFieldStyle(VInputStyle()) - Text("Get your bot token from @BotFather on Telegram") - .font(.caption).foregroundStyle(.secondary) - HStack { - if store.telegramSaveInProgress { - ProgressView().controlSize(.small) - } else { - Button("Save") { - store.saveTelegramToken(botToken: telegramBotTokenText) - telegramBotTokenText = "" - } - .disabled(telegramBotTokenText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - } - if let error = store.telegramError { - Text(error).foregroundStyle(.red).font(.caption) - } - - if store.telegramHasBotToken { - Divider() - settingsGuardianRow(channel: "telegram") - } - } - - Section("SMS Channel (Twilio)") { - if store.twilioHasCredentials { - HStack(spacing: 6) { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - .font(.system(size: 14)) - Text("Credentials configured") - .foregroundStyle(.secondary) - Spacer() - if store.twilioSaveInProgress { - ProgressView() - .controlSize(.small) - } else { - Button("Clear Credentials") { - store.clearTwilioCredentials() - } - .tint(.red) - } - } - } else { - TextField("Account SID", text: $twilioAccountSidText) - .textFieldStyle(VInputStyle()) - SecureField("Auth Token", text: $twilioAuthTokenText) - .textFieldStyle(VInputStyle()) - HStack { - Spacer() - if store.twilioSaveInProgress { - ProgressView() - .controlSize(.small) - } else { - Button("Save Credentials") { - store.saveTwilioCredentials( - accountSid: twilioAccountSidText, - authToken: twilioAuthTokenText - ) - twilioAuthTokenText = "" - } - .disabled( - twilioAccountSidText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - twilioAuthTokenText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - ) - } - } - } - - Divider() - - HStack { - Text("Assigned Number") - .foregroundStyle(.secondary) - Spacer() - Text(store.twilioPhoneNumber ?? "Not assigned") - .font(.body.monospaced()) - .foregroundStyle(store.twilioPhoneNumber == nil ? .tertiary : .primary) - } - - HStack { - TextField("Assign existing number (+1555...)", text: $twilioPhoneNumberText) - .textFieldStyle(VInputStyle()) - Button("Assign") { - store.assignTwilioNumber(phoneNumber: twilioPhoneNumberText) - twilioPhoneNumberText = "" - } - .disabled( - twilioPhoneNumberText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - store.twilioSaveInProgress - ) - } - - HStack { - TextField("Area code (optional)", text: $twilioAreaCodeText) - .textFieldStyle(VInputStyle()) - TextField("Country", text: $twilioCountryText) - .textFieldStyle(VInputStyle()) - .frame(width: 80) - Button("Provision") { - store.provisionTwilioNumber( - areaCode: twilioAreaCodeText, - country: twilioCountryText - ) - } - .disabled(store.twilioSaveInProgress) - } - - HStack { - if store.twilioListInProgress { - ProgressView() - .controlSize(.small) - } - Button("Refresh Numbers") { - store.refreshTwilioNumbers() - } - .disabled(store.twilioListInProgress) - } - - if !store.twilioNumbers.isEmpty { - ForEach(store.twilioNumbers, id: \.phoneNumber) { number in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(number.phoneNumber) - .font(.body.monospaced()) - Text(number.friendlyName) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button("Use") { - store.assignTwilioNumber(phoneNumber: number.phoneNumber) - } - .disabled(store.twilioSaveInProgress) - } - } - } - - if let warning = store.twilioWarning { - Text(warning) - .font(.caption) - .foregroundColor(.orange) - } - - if let error = store.twilioError { - Text(error) - .font(.caption) - .foregroundColor(.red) - } - - if store.twilioHasCredentials { - Divider() - settingsGuardianRow(channel: "sms") - } - } - - Section("Public Ingress") { - Toggle("Enable Public Ingress", isOn: Binding( - get: { store.ingressEnabled }, - set: { store.setIngressEnabled($0) } - )) - .disabled(store.ingressPublicBaseUrl.isEmpty && !store.ingressEnabled) - - HStack(alignment: .top, spacing: 6) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - .font(.system(size: 12)) - Text("Setting a public base URL may expose this computer to the public internet. Use with caution.") - .font(.caption) - .foregroundStyle(.secondary) - } - - TextField("Public Ingress URL (e.g. https://abc123.ngrok-free.app)", text: $ingressUrlText) - .focused($isIngressUrlFocused) - .textFieldStyle(VInputStyle()) - - HStack { - Spacer() - Button("Save") { - store.saveIngressPublicBaseUrl(ingressUrlText) - } - } - - Divider() - - HStack { - Text("Local Gateway Target") - .font(.caption) - .foregroundStyle(.secondary) - Spacer() - } - - HStack(spacing: 6) { - Text(store.localGatewayTarget) - .font(.body.monospaced()) - .textSelection(.enabled) - Spacer() - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(store.localGatewayTarget, forType: .string) - } label: { - Image(systemName: "doc.on.doc") - .font(.system(size: 12)) - } - .buttonStyle(.borderless) - .accessibilityLabel("Copy gateway address") - .help("Copy address") - } - - Text("Point your tunnel service at this local address.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Computer Use") { - HStack { - Text("Max steps per session") - Spacer() - Text("\(Int(store.maxSteps))") - .monospacedDigit() - .foregroundStyle(.secondary) - } - Slider(value: $store.maxSteps, in: 10...100, step: 10) - } - - Section("Voice Activation") { - Picker("Activation key", selection: $activationKey) { - ForEach(ActivationKey.allCases, id: \.self) { key in - Text(key.displayName).tag(key) - } - } - .onChange(of: activationKey) { _, newValue in - UserDefaults.standard.set(newValue.rawValue, forKey: "activationKey") - } - - Text("Hold the activation key to start voice input. Set to Off to disable voice activation.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Notifications") { - Toggle("Notify when tasks complete", isOn: $store.activityNotificationsEnabled) - - Text("Get notified when computer-use sessions finish so you don't need to watch progress.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Media Embeds") { - Toggle("Auto media embeds", isOn: Binding( - get: { store.mediaEmbedsEnabled }, - set: { store.setMediaEmbedsEnabled($0) } - )) - - Text("Automatically embed images, videos, and other media shared in chat messages.") - .font(.caption) - .foregroundStyle(.secondary) - - if store.mediaEmbedsEnabled { - Divider() - - Text("Video Domain Allowlist") - .font(.subheadline) - .fontWeight(.semibold) - - HStack { - TextField("Add domain (e.g. example.com)", text: $newAllowlistDomain) - .textFieldStyle(VInputStyle()) - Button("Add") { - let domain = newAllowlistDomain - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !domain.isEmpty else { return } - var domains = store.mediaEmbedVideoAllowlistDomains - domains.append(domain) - store.setMediaEmbedVideoAllowlistDomains(domains) - newAllowlistDomain = "" - } - .disabled(newAllowlistDomain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - - ForEach(store.mediaEmbedVideoAllowlistDomains, id: \.self) { domain in - HStack { - Text(domain) - .font(.body) - Spacer() - Button { - var domains = store.mediaEmbedVideoAllowlistDomains - domains.removeAll { $0 == domain } - store.setMediaEmbedVideoAllowlistDomains(domains) - } label: { - Image(systemName: "trash") - .foregroundStyle(.red) - } - .buttonStyle(.borderless) - } - } - - HStack { - Spacer() - Button("Reset to Defaults") { - store.setMediaEmbedVideoAllowlistDomains(MediaEmbedSettings.defaultDomains) - } - .font(.caption) - } - } - } - - Section("Permissions") { - HStack { - Image(systemName: accessibilityGranted ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(accessibilityGranted ? .green : .red) - Text("Accessibility") - Spacer() - if !accessibilityGranted { - Button("Grant") { - _ = PermissionManager.accessibilityStatus(prompt: true) - checkPermissions() - } - } - } - - HStack { - Image(systemName: screenRecordingGranted ? "checkmark.circle.fill" : "xmark.circle.fill") - .foregroundStyle(screenRecordingGranted ? .green : .red) - Text("Screen Recording") - Spacer() - if !screenRecordingGranted { - Button("Check") { - let status = PermissionManager.screenRecordingStatus() - screenRecordingGranted = status == .granted - } - } - } - } - - if let daemonClient { - Section("Skills") { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Manage Skills") - Text("Enable, disable, and browse available skills") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button("Manage Skills...") { - skillsViewModel = SkillsSettingsViewModel(daemonClient: daemonClient) - showingSkills = true - } - } - } - - Section("Trust Rules") { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("Manage Trust Rules") - Text("Control which tool actions are automatically allowed or denied") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button("Manage Trust Rules...") { - daemonClient.isTrustRulesSheetOpen = true - showingTrustRules = true - } - .disabled(store.isAnyTrustRulesSheetOpen) - } - } - } - - Section("Privacy & Security") { - PrivacyBullet(icon: "eye.slash", text: "AI only runs when you explicitly trigger it") - PrivacyBullet(icon: "lock.shield", text: "API key stored in macOS Keychain") - PrivacyBullet(icon: "xmark.shield", text: "Your data is not used to train AI models") - PrivacyBullet(icon: "internaldrive", text: "Session logs and knowledge stored locally on your Mac") - - Button("Learn More") { - showingPrivacy = true - } - .font(.caption) - } - - #if DEBUG - if let daemonClient { - Section("Developer") { - Button("View Environment Variables") { - appEnvVars = ProcessInfo.processInfo.environment - .sorted(by: { $0.key < $1.key }) - .map { ($0.key, $0.value) } - daemonEnvVars = [] - daemonClient.onEnvVarsResponse = { response in - Task { @MainActor in - self.daemonEnvVars = response.vars - .sorted(by: { $0.key < $1.key }) - .map { ($0.key, $0.value) } - } - } - try? daemonClient.sendEnvVarsRequest() - showingEnvVars = true - } - } - } - #endif - } - .formStyle(.grouped) - .frame(width: 450, height: 700) - .onAppear { - store.refreshAPIKeyState() - store.refreshVercelKeyState() - store.refreshTwitterStatus() - store.refreshTelegramStatus() - store.refreshTwilioStatus() - store.refreshIngressConfig() - ingressUrlText = store.ingressPublicBaseUrl - checkPermissions() - store.refreshChannelGuardianStatus(channel: "telegram") - store.refreshChannelGuardianStatus(channel: "sms") - } - .onDisappear { - #if DEBUG - daemonClient?.onEnvVarsResponse = nil - #endif - } - .onReceive(permissionTimer) { _ in - checkPermissions() - } - .onChange(of: store.ingressPublicBaseUrl) { _, newValue in - // Only sync from store when the field is not focused, so - // background IPC responses don't overwrite in-progress edits. - if !isIngressUrlFocused { - ingressUrlText = newValue - } - } - .onChange(of: isIngressUrlFocused) { _, focused in - // Re-sync when focus leaves so any updates skipped while the - // user was editing are applied once they're done. - if !focused { - ingressUrlText = store.ingressPublicBaseUrl - } - } - .sheet(isPresented: $showingSkills, onDismiss: { - skillsViewModel = nil - }) { - if let vm = skillsViewModel { - SkillsSettingsView(viewModel: vm) - } - } - .sheet(isPresented: $showingTrustRules) { - if let daemonClient { - TrustRulesView(daemonClient: daemonClient) - } - } - .sheet(isPresented: $showingPrivacy) { - PrivacyDetailView() - } - #if DEBUG - .sheet(isPresented: $showingEnvVars) { - EnvVarsSheetView(appEnvVars: appEnvVars, daemonEnvVars: daemonEnvVars) - } - #endif - } - - private func checkPermissions() { - accessibilityGranted = PermissionManager.accessibilityStatus() == .granted - let status = PermissionManager.screenRecordingStatus() - screenRecordingGranted = status == .granted - } - - @ViewBuilder - private func settingsGuardianRow(channel: String) -> some View { - let identity: String? = channel == "telegram" ? store.telegramGuardianIdentity : store.smsGuardianIdentity - let verified: Bool = channel == "telegram" ? store.telegramGuardianVerified : store.smsGuardianVerified - let inProgress: Bool = channel == "telegram" ? store.telegramGuardianVerificationInProgress : store.smsGuardianVerificationInProgress - let instruction: String? = channel == "telegram" ? store.telegramGuardianInstruction : store.smsGuardianInstruction - let error: String? = channel == "telegram" ? store.telegramGuardianError : store.smsGuardianError - - VStack(alignment: .leading, spacing: 4) { - Text("Guardian Verification") - .font(.headline) - - if verified { - HStack { - Image(systemName: "checkmark.shield.fill").foregroundColor(.green) - Text(identity.map { "Verified: \($0)" } ?? "Verified") - Spacer() - Button("Revoke") { - store.revokeChannelGuardian(channel: channel) - } - } - } else if inProgress { - VStack(alignment: .leading, spacing: 4) { - HStack { - ProgressView().controlSize(.small) - Text("Generating verification code...") - } - Text("You will get a code to send as /guardian_verify from your \(channel == "telegram" ? "Telegram account" : "SMS number").") - .font(.caption) - .foregroundStyle(.secondary) - } - } else if let instruction { - Text(instruction) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .padding(4) - .background(Color.secondary.opacity(0.1)) - .cornerRadius(4) - } else { - HStack { - Image(systemName: "shield.slash").foregroundStyle(.secondary) - Text("Not verified").foregroundStyle(.secondary) - Spacer() - Button("Verify Guardian") { - store.startChannelGuardianVerification(channel: channel) - } - } - } - - if let error { - Text(error).foregroundStyle(.red).font(.caption) - } - } - } - -} - -// MARK: - Knowledge Section - -private struct KnowledgeSection: View { - @ObservedObject var store: KnowledgeStore - @State private var showingEntries = false - - var body: some View { - HStack { - Text("Knowledge entries") - Spacer() - Text("\(store.entries.count)") - .monospacedDigit() - .foregroundStyle(.secondary) - } - - HStack { - Button("View Entries") { - showingEntries = true - } - .disabled(store.entries.isEmpty) - - Spacer() - - Button("Clear All") { - store.clearAll() - } - .tint(.red) - .disabled(store.entries.isEmpty) - } - .sheet(isPresented: $showingEntries) { - KnowledgeEntriesView(store: store) - } - } -} - -// MARK: - Privacy & Security - -private struct PrivacyBullet: View { - let icon: String - let text: String - - var body: some View { - HStack(alignment: .top, spacing: 8) { - Image(systemName: icon) - .foregroundStyle(.secondary) - .frame(width: 16) - Text(text) - .font(.caption) - .foregroundStyle(.secondary) - } - } -} - -private struct PrivacyDetailView: View { - @Environment(\.dismiss) var dismiss - - var body: some View { - VStack(spacing: 0) { - HStack { - Text("Privacy & Security") - .font(.headline) - Spacer() - Button("Done") { dismiss() } - } - .padding() - - Divider() - - ScrollView { - VStack(alignment: .leading, spacing: 20) { - privacySection( - title: "How Velly Works", - items: [ - "Velly only activates AI when you explicitly trigger a task or use voice input. It does not run in the background unless you opt in.", - "You are always in control. You can disable the ambient agent, revoke permissions, or clear stored data at any time from Settings.", - ] - ) - - privacySection( - title: "What Data Leaves Your Mac", - items: [ - "When you run a task: screenshots (compressed, max 1280x720) and UI element data (window titles, button labels, text field values) are sent to the Anthropic API over HTTPS.", - "Voice input: speech is transcribed on-device using Apple Speech Recognition. Only the final text is sent to Anthropic as part of the task.", - ] - ) - - privacySection( - title: "What Stays on Your Mac", - items: [ - "Session logs (task descriptions, action history, UI element data) are stored in ~/Library/Application Support/vellum-assistant/logs/.", - "Knowledge entries and insights from the ambient agent are stored locally as JSON files.", - "Your API key is stored in the macOS Keychain, encrypted and accessible only when your Mac is unlocked.", - "Screenshots are sent to Anthropic for inference but are never saved to disk.", - ] - ) - - privacySection( - title: "AI Model Usage", - items: [ - "Velly uses Anthropic's Claude models (Sonnet for tasks, Haiku for ambient analysis). All requests go through Anthropic's API.", - "Your data is not used to train AI models. Anthropic's commercial API terms prohibit using customer inputs for model training.", - "A safety layer actively detects and blocks sensitive data — passwords, credit card numbers, and SSNs — before any action is executed, in addition to AI-level instructions to never type such data.", - ] - ) - - privacySection( - title: "Permissions", - items: [ - "Accessibility: required to read UI elements (button labels, text fields) and to control your Mac (clicking, typing) during tasks.", - "Screen Recording: required to capture screenshots so the AI can see what's on screen.", - "Microphone (optional): only used for voice input. Speech recognition runs on-device via Apple's API.", - ] - ) - - privacySection( - title: "Security Measures", - items: [ - "All API communication uses HTTPS with TLS encryption.", - "A safety layer verifies every AI action before execution, blocking destructive key combinations and detecting action loops.", - "Text input uses a temporary clipboard swap (save, paste, restore) rather than keystroke injection, preventing keylogging exposure.", - "You can press Escape at any time to immediately cancel a running session.", - ] - ) - - privacySection( - title: "Data You Can Clear", - items: [ - "API key: Settings > Anthropic API Key > Clear", - "Knowledge entries: Settings > Ambient Agent > Clear All", - "Session logs: delete files in ~/Library/Application Support/vellum-assistant/logs/", - ] - ) - - Text("If you have questions or concerns, contact us at privacy@vellum.ai") - .font(.caption) - .foregroundStyle(.tertiary) - } - .padding() - } - } - .frame(width: 520, height: 500) - } - - private func privacySection(title: String, items: [String]) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.subheadline) - .fontWeight(.semibold) - ForEach(items, id: \.self) { item in - HStack(alignment: .top, spacing: 6) { - Text("\u{2022}") - .foregroundStyle(.tertiary) - Text(item) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } -} - -private struct KnowledgeEntriesView: View { - @ObservedObject var store: KnowledgeStore - @Environment(\.dismiss) var dismiss - - var body: some View { - VStack(spacing: 0) { - HStack { - Text("Knowledge Entries (\(store.entries.count))") - .font(.headline) - Spacer() - Button("Done") { dismiss() } - } - .padding() - - Divider() - - if store.entries.isEmpty { - Spacer() - Text("No entries yet") - .foregroundStyle(.secondary) - Spacer() - } else { - List { - ForEach(store.entries.reversed()) { entry in - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 4) { - Text(entry.observation) - HStack(spacing: 4) { - Text(entry.sourceApp) - Text("\u{00b7}") - Text(entry.timestamp, style: .relative) - Text("ago") - Text("\u{00b7}") - Text("\(Int(entry.confidence * 100))%") - } - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button { - store.removeEntry(id: entry.id) - } label: { - Image(systemName: "trash") - .foregroundStyle(.red) - } - .buttonStyle(.borderless) - } - .padding(.vertical, 2) - } - } - } - } - .frame(width: 500, height: 400) - } -} - -// MARK: - Environment Variables Sheet (Debug Only) - -#if DEBUG -private struct EnvVarsSheetView: View { - let appEnvVars: [(String, String)] - let daemonEnvVars: [(String, String)] - @Environment(\.dismiss) var dismiss - - var body: some View { - VStack(spacing: 0) { - HStack { - Text("Environment Variables") - .font(.headline) - Spacer() - Button("Done") { dismiss() } - } - .padding() - - Divider() - - ScrollView { - VStack(alignment: .leading, spacing: 16) { - envVarsSection(title: "App Process", vars: appEnvVars) - envVarsSection(title: "Daemon Process", vars: daemonEnvVars) - } - .padding() - } - } - .frame(width: 600, height: 500) - } - - private func envVarsSection(title: String, vars: [(String, String)]) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.subheadline) - .fontWeight(.semibold) - if vars.isEmpty { - Text("Loading...") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(vars, id: \.0) { key, value in - HStack(alignment: .top, spacing: 8) { - Text(key) - .font(.caption) - .fontWeight(.medium) - .frame(width: 200, alignment: .trailing) - Text(value) - .font(.caption) - .foregroundStyle(.secondary) - .textSelection(.enabled) - Spacer() - } - } - } - } - } -} -#endif - -struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView(store: SettingsStore()) - } -} diff --git a/clients/macos/vellum-assistantTests/SettingsPanelMediaControlsTests.swift b/clients/macos/vellum-assistantTests/SettingsPanelMediaControlsTests.swift index ea40ce3953f..ecdd2a998ca 100644 --- a/clients/macos/vellum-assistantTests/SettingsPanelMediaControlsTests.swift +++ b/clients/macos/vellum-assistantTests/SettingsPanelMediaControlsTests.swift @@ -87,33 +87,25 @@ final class SettingsPanelMediaControlsTests: XCTestCase { XCTAssertEqual(store.mediaEmbedVideoAllowlistDomains, MediaEmbedSettings.defaultDomains) } - // MARK: - Both surfaces stay in sync through shared store + // MARK: - Store updates reflect across observers - func testBothSurfacesShareStoreToggle() { - // A single SettingsStore instance drives both SettingsView and - // SettingsPanel. Toggling via the store API should be visible to - // any view observing that store. + func testStoreToggleReflectsAcrossSurfaces() { let store = makeStore(enabled: false) - // Simulate SettingsPanel toggling embeds on + // Toggling via the store API should be visible to any observer. store.setMediaEmbedsEnabled(true) - // SettingsView reading the same store sees the updated value - let view = SettingsView(store: store) - XCTAssertTrue(view.store.mediaEmbedsEnabled) + XCTAssertTrue(store.mediaEmbedsEnabled) } - func testBothSurfacesShareStoreDomains() { + func testStoreDomainUpdateReflectsAcrossSurfaces() { let store = makeStore(enabled: true, domains: ["youtube.com"]) - // Simulate SettingsPanel adding a domain var domains = store.mediaEmbedVideoAllowlistDomains domains.append("newsite.com") store.setMediaEmbedVideoAllowlistDomains(domains) - // SettingsView reading the same store sees the updated domains - let view = SettingsView(store: store) - XCTAssertEqual(view.store.mediaEmbedVideoAllowlistDomains, ["youtube.com", "newsite.com"]) + XCTAssertEqual(store.mediaEmbedVideoAllowlistDomains, ["youtube.com", "newsite.com"]) } func testToggleChangePersistedAndReloadable() { diff --git a/clients/macos/vellum-assistantTests/SettingsViewMediaAllowlistTests.swift b/clients/macos/vellum-assistantTests/SettingsViewMediaAllowlistTests.swift index b5ee26a531a..938e0cbda04 100644 --- a/clients/macos/vellum-assistantTests/SettingsViewMediaAllowlistTests.swift +++ b/clients/macos/vellum-assistantTests/SettingsViewMediaAllowlistTests.swift @@ -39,18 +39,16 @@ final class SettingsViewMediaAllowlistTests: XCTestCase { return SettingsStore(configPath: configPath) } - // MARK: - Allowlist domains from store are accessible via SettingsView + // MARK: - Allowlist domains from store are accessible - func testAllowlistDomainsAccessibleThroughView() { + func testAllowlistDomainsAccessibleThroughStore() { let store = makeStore(enabled: true, domains: ["youtube.com", "vimeo.com"]) - let view = SettingsView(store: store) - XCTAssertEqual(view.store.mediaEmbedVideoAllowlistDomains, ["youtube.com", "vimeo.com"]) + XCTAssertEqual(store.mediaEmbedVideoAllowlistDomains, ["youtube.com", "vimeo.com"]) } func testDefaultDomainsLoadedWhenNoneConfigured() { let store = makeStore(enabled: true) - let view = SettingsView(store: store) - XCTAssertEqual(view.store.mediaEmbedVideoAllowlistDomains, MediaEmbedSettings.defaultDomains) + XCTAssertEqual(store.mediaEmbedVideoAllowlistDomains, MediaEmbedSettings.defaultDomains) } // MARK: - Adding a domain updates the store diff --git a/clients/macos/vellum-assistantTests/SettingsViewMediaToggleTests.swift b/clients/macos/vellum-assistantTests/SettingsViewMediaToggleTests.swift index ba27a606e37..d18fecc9273 100644 --- a/clients/macos/vellum-assistantTests/SettingsViewMediaToggleTests.swift +++ b/clients/macos/vellum-assistantTests/SettingsViewMediaToggleTests.swift @@ -64,19 +64,15 @@ final class SettingsViewMediaToggleTests: XCTestCase { XCTAssertFalse(store.mediaEmbedsEnabled) } - // MARK: - SettingsView creates without error when store has media embeds + // MARK: - Store reflects media embeds state - func testSettingsViewInitializesWithMediaEmbedsStore() { + func testStoreReflectsMediaEmbedsEnabled() { let store = makeStore(enabled: true) - let view = SettingsView(store: store) - // The view should initialize without crashing; the store should - // still reflect the expected state after being wired up. - XCTAssertTrue(view.store.mediaEmbedsEnabled) + XCTAssertTrue(store.mediaEmbedsEnabled) } - func testSettingsViewInitializesWithMediaEmbedsDisabled() { + func testStoreReflectsMediaEmbedsDisabled() { let store = makeStore(enabled: false) - let view = SettingsView(store: store) - XCTAssertFalse(view.store.mediaEmbedsEnabled) + XCTAssertFalse(store.mediaEmbedsEnabled) } }