feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
@@ -32,13 +32,9 @@ enum MacMetrics {
|
||||
/// both this value, so the card breathes evenly. Applied as `VStack(spacing:)`
|
||||
/// between rows and `.padding(.vertical:)` on the card body.
|
||||
static let settingsRowGap: CGFloat = Spacing.md
|
||||
/// Provider menu trigger width (legacy).
|
||||
static let selectWidth: CGFloat = 200
|
||||
/// Text-field max width for provider credential rows (narrower than the
|
||||
/// old 360pt so long keys truncate instead of wrapping when the window shrinks).
|
||||
static let fieldWidth: CGFloat = 280
|
||||
/// Legacy alias used by older call sites; prefer `fieldWidth`.
|
||||
static let controlWidth: CGFloat = fieldWidth
|
||||
/// Horizontal inset inside settings cards — matches History / Dictionary rows.
|
||||
static let settingsCardInset: CGFloat = Spacing.md
|
||||
/// Below this row width, provider rows stack label above control.
|
||||
@@ -266,19 +262,6 @@ struct MacInlineRow<Control: View>: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backwards-compatible alias: title + optional subtitle + trailing control.
|
||||
struct MacFormSubtitleRow<Control: View>: View {
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
@ViewBuilder var control: () -> Control
|
||||
|
||||
var body: some View {
|
||||
MacInlineRow(title: title, subtitle: subtitle) {
|
||||
control()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tappable navigation / action row inside a settings card.
|
||||
struct MacFormLinkRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@@ -324,8 +307,6 @@ struct MacProviderSettingRow<Content: View>: View {
|
||||
|
||||
let title: String
|
||||
var subtitle: String? = nil
|
||||
/// Retained for source compatibility; the control now fills its column.
|
||||
var controlMaxWidth: CGFloat? = nil
|
||||
/// Cross-axis alignment between the label column and the control. Provider
|
||||
/// rows keep `.top` (model row grows a status line below its field); single
|
||||
/// control rows can pass `.center` to vertically center label and control.
|
||||
@@ -431,22 +412,6 @@ struct MacSettingsToolButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Legacy setting row
|
||||
|
||||
/// Fixed label column + left-aligned control. Prefer `MacProviderSettingRow`
|
||||
/// for provider configuration cards.
|
||||
struct MacSettingRow<Content: View>: View {
|
||||
let title: String
|
||||
var controlMaxWidth: CGFloat? = nil
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
MacProviderSettingRow(title: title, controlMaxWidth: controlMaxWidth) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page header
|
||||
|
||||
/// Capsule-shaped primary action aligned with page-header search controls.
|
||||
|
||||
@@ -88,11 +88,16 @@ struct MacSettingsICloudSyncRow: View {
|
||||
do {
|
||||
try await MacICloudSyncBootstrap.dictionarySync.enableSync()
|
||||
} catch let error as PersonalDictionaryCloudSyncError {
|
||||
MacICloudSyncBootstrap.settingsSync.disableSync()
|
||||
isEnabled = false
|
||||
if case .payloadTooLarge = error {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
|
||||
} else {
|
||||
do {
|
||||
try MacICloudSyncBootstrap.settingsSync.disableSync()
|
||||
isEnabled = false
|
||||
if case .payloadTooLarge = error {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.dictTooLarge", language: language)
|
||||
} else {
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
} catch {
|
||||
reloadFromStore()
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
isApplyingToggle = false
|
||||
@@ -100,7 +105,7 @@ struct MacSettingsICloudSyncRow: View {
|
||||
}
|
||||
reloadFromStore()
|
||||
} catch {
|
||||
isEnabled = false
|
||||
reloadFromStore()
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
isApplyingToggle = false
|
||||
@@ -108,9 +113,16 @@ struct MacSettingsICloudSyncRow: View {
|
||||
}
|
||||
|
||||
private func disableSync() {
|
||||
MacICloudSyncBootstrap.settingsSync.disableSync()
|
||||
isEnabled = false
|
||||
syncErrorMessage = nil
|
||||
isApplyingToggle = true
|
||||
do {
|
||||
try MacICloudSyncBootstrap.settingsSync.disableSync()
|
||||
reloadFromStore()
|
||||
} catch {
|
||||
reloadFromStore()
|
||||
syncErrorMessage = MacL10n.string("mac.sync.error.generic", language: language)
|
||||
}
|
||||
isApplyingToggle = false
|
||||
}
|
||||
|
||||
private func syncNow() {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// OSGKeyboard · Mac
|
||||
//
|
||||
// A short first-run setup for the macOS app. It is intentionally separate
|
||||
// from iOS onboarding because Mac needs Accessibility and optional Sherpa setup.
|
||||
// from iOS onboarding because Mac needs Accessibility and local-model setup.
|
||||
// The current local runtime defaults to Qwen3 MLX with Apple Speech fallback;
|
||||
// Sherpa identifiers and install records are retained only for migration compatibility.
|
||||
//
|
||||
// Visual language mirrors the iOS onboarding: an ambient top gradient, a
|
||||
// glowing hero icon, a large title block, and elongated capsule progress
|
||||
|
||||
@@ -272,8 +272,10 @@ struct MacProviderModelRow: View {
|
||||
let title: String
|
||||
let placeholder: String
|
||||
@Binding var model: String
|
||||
let providerIdentity: String
|
||||
let endpointIdentity: String
|
||||
let apiKey: String
|
||||
let fetchModels: () async throws -> [String]
|
||||
let makeFetchModelsRequest: @MainActor () -> ProviderToolRequest<[String]>
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var models: [String] = []
|
||||
@@ -282,6 +284,7 @@ struct MacProviderModelRow: View {
|
||||
@State private var failed = false
|
||||
@State private var isDropdownOpen = false
|
||||
@State private var fieldWidth: CGFloat = 0
|
||||
@State private var requestCoordinator = ProviderToolRequestCoordinator()
|
||||
|
||||
private let chevronWidth: CGFloat = 28
|
||||
private let dropdownMaxHeight: CGFloat = 240
|
||||
@@ -313,7 +316,7 @@ struct MacProviderModelRow: View {
|
||||
disabled: isRunning || trimmedAPIKey.isEmpty
|
||||
) {
|
||||
isDropdownOpen = false
|
||||
Task { await runFetchModels() }
|
||||
runFetchModels()
|
||||
}
|
||||
|
||||
if isRunning {
|
||||
@@ -325,6 +328,11 @@ struct MacProviderModelRow: View {
|
||||
statusMessage
|
||||
}
|
||||
}
|
||||
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
|
||||
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
|
||||
.onChange(of: apiKey) { _, _ in invalidateRequest() }
|
||||
.onChange(of: model) { _, _ in invalidateRequestIfRunning() }
|
||||
.onDisappear { invalidateRequest() }
|
||||
}
|
||||
|
||||
/// Editable model id + trailing chevron. Model list is a true popover so the
|
||||
@@ -332,7 +340,7 @@ struct MacProviderModelRow: View {
|
||||
private var comboField: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
return ZStack(alignment: .trailing) {
|
||||
TextField(text: $model, prompt: Text(verbatim: placeholder)) {
|
||||
TextField(text: editableModelBinding, prompt: Text(verbatim: placeholder)) {
|
||||
Text(title)
|
||||
}
|
||||
.labelsHidden()
|
||||
@@ -400,6 +408,7 @@ struct MacProviderModelRow: View {
|
||||
private func dropdownRow(_ modelId: String) -> some View {
|
||||
let isSelected = modelId == model
|
||||
return Button {
|
||||
invalidateRequest()
|
||||
model = modelId
|
||||
message = MacL10n.format(
|
||||
"mac.settings.modelSelected",
|
||||
@@ -445,31 +454,77 @@ struct MacProviderModelRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var editableModelBinding: Binding<String> {
|
||||
Binding(
|
||||
get: { model },
|
||||
set: { newValue in
|
||||
invalidateRequest()
|
||||
model = newValue
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runFetchModels() async {
|
||||
private func runFetchModels() {
|
||||
guard !trimmedAPIKey.isEmpty else {
|
||||
failed = true
|
||||
message = SharedL10n.string("providerTools.error.missingAPIKey", language: language)
|
||||
return
|
||||
}
|
||||
|
||||
let request = makeFetchModelsRequest()
|
||||
let currentModel = model
|
||||
let currentLanguage = language
|
||||
let runningMessage = MacL10n.string("mac.settings.loadingModels", language: currentLanguage)
|
||||
let emptyMessage = SharedL10n.string("providerTools.error.empty", language: currentLanguage)
|
||||
|
||||
isRunning = true
|
||||
failed = false
|
||||
defer { isRunning = false }
|
||||
message = runningMessage
|
||||
|
||||
let outcome = await ProviderToolRunner.runFetchModels(
|
||||
runningMessage: MacL10n.string("mac.settings.loadingModels", language: language),
|
||||
loadedMessage: { MacL10n.format("mac.settings.modelsLoaded", language: language, $0) },
|
||||
emptyMessage: SharedL10n.string("providerTools.error.empty", language: language),
|
||||
currentModel: model,
|
||||
fetchModels: fetchModels
|
||||
requestCoordinator.start(
|
||||
providerIdentity: request.providerIdentity,
|
||||
operation: {
|
||||
await ProviderToolRunner.runFetchModels(
|
||||
runningMessage: runningMessage,
|
||||
loadedMessage: {
|
||||
MacL10n.format("mac.settings.modelsLoaded", language: currentLanguage, $0)
|
||||
},
|
||||
emptyMessage: emptyMessage,
|
||||
currentModel: currentModel,
|
||||
fetchModels: request.operation
|
||||
)
|
||||
},
|
||||
commit: { outcome in
|
||||
isRunning = false
|
||||
switch outcome {
|
||||
case .cancelled:
|
||||
message = nil
|
||||
failed = false
|
||||
case .completed(let state, let selectedModel):
|
||||
models = state.models
|
||||
message = state.message
|
||||
failed = state.failed
|
||||
if let selectedModel {
|
||||
model = selectedModel
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
models = outcome.state.models
|
||||
message = outcome.state.message
|
||||
failed = outcome.state.failed
|
||||
if let selected = outcome.selectedModel {
|
||||
model = selected
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func invalidateRequest() {
|
||||
requestCoordinator.invalidate()
|
||||
isRunning = false
|
||||
message = nil
|
||||
failed = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func invalidateRequestIfRunning() {
|
||||
guard requestCoordinator.isRunning else { return }
|
||||
invalidateRequest()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,12 +534,17 @@ struct MacProviderToolsRow: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let title: String
|
||||
let validate: () async throws -> Void
|
||||
let providerIdentity: String
|
||||
let endpointIdentity: String
|
||||
let credentialIdentity: String
|
||||
let modelIdentity: String
|
||||
let makeValidateRequest: @MainActor () -> ProviderToolRequest<Void>
|
||||
let language: AppUILanguage
|
||||
|
||||
@State private var isRunning = false
|
||||
@State private var message: String?
|
||||
@State private var failed = false
|
||||
@State private var requestCoordinator = ProviderToolRequestCoordinator()
|
||||
|
||||
var body: some View {
|
||||
MacProviderSettingRow(title: title) {
|
||||
@@ -493,7 +553,7 @@ struct MacProviderToolsRow: View {
|
||||
title: MacL10n.string("mac.settings.validate", language: language),
|
||||
disabled: isRunning
|
||||
) {
|
||||
Task { await runValidate() }
|
||||
runValidate()
|
||||
}
|
||||
|
||||
if isRunning {
|
||||
@@ -508,21 +568,53 @@ struct MacProviderToolsRow: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: providerIdentity) { _, _ in invalidateRequest() }
|
||||
.onChange(of: endpointIdentity) { _, _ in invalidateRequest() }
|
||||
.onChange(of: credentialIdentity) { _, _ in invalidateRequest() }
|
||||
.onChange(of: modelIdentity) { _, _ in invalidateRequest() }
|
||||
.onDisappear { invalidateRequest() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func runValidate() async {
|
||||
private func runValidate() {
|
||||
let request = makeValidateRequest()
|
||||
let currentLanguage = language
|
||||
let runningMessage = MacL10n.string("mac.settings.validating", language: currentLanguage)
|
||||
let successMessage = MacL10n.string("mac.settings.validateSuccess", language: currentLanguage)
|
||||
|
||||
isRunning = true
|
||||
failed = false
|
||||
defer { isRunning = false }
|
||||
message = runningMessage
|
||||
|
||||
let outcome = await ProviderToolRunner.runValidate(
|
||||
runningMessage: MacL10n.string("mac.settings.validating", language: language),
|
||||
successMessage: MacL10n.string("mac.settings.validateSuccess", language: language),
|
||||
validate: validate
|
||||
requestCoordinator.start(
|
||||
providerIdentity: request.providerIdentity,
|
||||
operation: {
|
||||
await ProviderToolRunner.runValidate(
|
||||
runningMessage: runningMessage,
|
||||
successMessage: successMessage,
|
||||
validate: request.operation
|
||||
)
|
||||
},
|
||||
commit: { outcome in
|
||||
isRunning = false
|
||||
switch outcome {
|
||||
case .cancelled:
|
||||
message = nil
|
||||
failed = false
|
||||
case .completed(let state):
|
||||
message = state.message
|
||||
failed = state.failed
|
||||
}
|
||||
}
|
||||
)
|
||||
message = outcome.message
|
||||
failed = outcome.failed
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func invalidateRequest() {
|
||||
requestCoordinator.invalidate()
|
||||
isRunning = false
|
||||
message = nil
|
||||
failed = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -131,8 +131,10 @@ struct MacSettingsView: View {
|
||||
title: MacL10n.string("mac.settings.model", language: lang),
|
||||
placeholder: currentPolishProvider.defaultModel,
|
||||
model: $viewModel.config.model,
|
||||
providerIdentity: viewModel.config.providerId,
|
||||
endpointIdentity: viewModel.config.baseURL,
|
||||
apiKey: viewModel.config.apiKey,
|
||||
fetchModels: fetchMacLLMModels,
|
||||
makeFetchModelsRequest: makeMacLLMModelsRequest,
|
||||
language: lang
|
||||
)
|
||||
.id(viewModel.config.providerId)
|
||||
@@ -143,7 +145,11 @@ struct MacSettingsView: View {
|
||||
)
|
||||
MacProviderToolsRow(
|
||||
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
|
||||
validate: validateMacLLM,
|
||||
providerIdentity: viewModel.config.providerId,
|
||||
endpointIdentity: viewModel.config.baseURL,
|
||||
credentialIdentity: viewModel.config.apiKey,
|
||||
modelIdentity: viewModel.config.model,
|
||||
makeValidateRequest: makeMacLLMValidateRequest,
|
||||
language: lang
|
||||
)
|
||||
MacProviderSettingRow(title: MacL10n.string("mac.settings.translation", language: lang)) {
|
||||
@@ -179,7 +185,11 @@ struct MacSettingsView: View {
|
||||
}
|
||||
MacProviderToolsRow(
|
||||
title: MacL10n.string("mac.settings.connectionCheck", language: lang),
|
||||
validate: validateMacASR,
|
||||
providerIdentity: viewModel.config.asrProviderId,
|
||||
endpointIdentity: viewModel.config.asrBaseURL,
|
||||
credentialIdentity: viewModel.config.asrApiKey,
|
||||
modelIdentity: viewModel.config.asrModel,
|
||||
makeValidateRequest: makeMacASRValidateRequest,
|
||||
language: lang
|
||||
)
|
||||
}
|
||||
@@ -226,14 +236,6 @@ struct MacSettingsView: View {
|
||||
isSecret: true
|
||||
)
|
||||
}
|
||||
MacProviderNoteRow(
|
||||
text: MacL10n.string(
|
||||
macVolcengineFields.usesAPIKeyAuth
|
||||
? "mac.settings.volcengineNoteApiKey"
|
||||
: "mac.settings.volcengineNoteAppToken",
|
||||
language: lang
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -256,8 +258,10 @@ struct MacSettingsView: View {
|
||||
title: MacL10n.string("mac.settings.asrModel", language: lang),
|
||||
placeholder: CloudASRModelCatalog.defaultModel(for: viewModel.config.asrProviderId),
|
||||
model: $viewModel.config.asrModel,
|
||||
providerIdentity: viewModel.config.asrProviderId,
|
||||
endpointIdentity: viewModel.config.asrBaseURL,
|
||||
apiKey: viewModel.config.asrApiKey,
|
||||
fetchModels: fetchMacASRModels,
|
||||
makeFetchModelsRequest: makeMacASRModelsRequest,
|
||||
language: lang
|
||||
)
|
||||
.id(viewModel.config.asrProviderId)
|
||||
@@ -443,39 +447,65 @@ struct MacSettingsView: View {
|
||||
viewModel.config.asrApiKey = fields.encodedAPIKey
|
||||
}
|
||||
|
||||
private func validateMacLLM() async throws {
|
||||
let client = LLMClientFactory.make(
|
||||
providerId: viewModel.config.providerId,
|
||||
baseURL: viewModel.config.baseURL,
|
||||
apiKey: viewModel.config.apiKey,
|
||||
model: viewModel.config.model,
|
||||
thinkingEnabled: viewModel.config.llmThinkingEnabled
|
||||
)
|
||||
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
|
||||
@MainActor
|
||||
private func makeMacLLMValidateRequest() -> ProviderToolRequest<Void> {
|
||||
let providerID = viewModel.config.providerId
|
||||
let baseURL = viewModel.config.baseURL
|
||||
let apiKey = viewModel.config.apiKey
|
||||
let model = viewModel.config.model
|
||||
let thinkingEnabled = viewModel.config.llmThinkingEnabled
|
||||
return ProviderToolRequest(providerIdentity: providerID) {
|
||||
let client = LLMClientFactory.make(
|
||||
providerId: providerID,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
_ = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.")
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchMacLLMModels() async throws -> [String] {
|
||||
try await ProviderModelService.listLLMModels(
|
||||
providerId: viewModel.config.providerId,
|
||||
baseURL: viewModel.config.baseURL,
|
||||
apiKey: viewModel.config.apiKey,
|
||||
currentModel: viewModel.config.model
|
||||
)
|
||||
@MainActor
|
||||
private func makeMacLLMModelsRequest() -> ProviderToolRequest<[String]> {
|
||||
let providerID = viewModel.config.providerId
|
||||
let baseURL = viewModel.config.baseURL
|
||||
let apiKey = viewModel.config.apiKey
|
||||
let currentModel = viewModel.config.model
|
||||
return ProviderToolRequest(providerIdentity: providerID) {
|
||||
try await ProviderModelService.listLLMModels(
|
||||
providerId: providerID,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
currentModel: currentModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func validateMacASR() async throws {
|
||||
@MainActor
|
||||
private func makeMacASRValidateRequest() -> ProviderToolRequest<Void> {
|
||||
let persisted = AppGroupStore(defaults: viewModel.defaults)
|
||||
let store = LiveConfigurationStore(config: viewModel.config, fallback: persisted)
|
||||
try await CloudASRConnectionCheck.validate(store: store)
|
||||
let providerID = viewModel.config.asrProviderId
|
||||
return ProviderToolRequest(providerIdentity: providerID) {
|
||||
try await CloudASRConnectionCheck.validate(store: store)
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchMacASRModels() async throws -> [String] {
|
||||
try await ProviderModelService.listASRModels(
|
||||
providerId: viewModel.config.asrProviderId,
|
||||
baseURL: viewModel.config.asrBaseURL,
|
||||
apiKey: viewModel.config.asrApiKey,
|
||||
currentModel: viewModel.config.asrModel
|
||||
)
|
||||
@MainActor
|
||||
private func makeMacASRModelsRequest() -> ProviderToolRequest<[String]> {
|
||||
let providerID = viewModel.config.asrProviderId
|
||||
let baseURL = viewModel.config.asrBaseURL
|
||||
let apiKey = viewModel.config.asrApiKey
|
||||
let currentModel = viewModel.config.asrModel
|
||||
return ProviderToolRequest(providerIdentity: providerID) {
|
||||
try await ProviderModelService.listASRModels(
|
||||
providerId: providerID,
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
currentModel: currentModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bindings
|
||||
|
||||
Reference in New Issue
Block a user