From fea97099efc2b18d5fa186ae659645eba9e1360c Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Sun, 4 Oct 2026 17:51:13 +0800 Subject: [PATCH] feat(discover): disable Discover settings while discovery is switched off Everything under the AI discovery switch follows it, and what was entered is kept. The status line and the settings summary say Off before naming anything missing, since nothing below can be changed then. --- .../app/ui/settings/AiDiscoverySettings.kt | 44 +++++++++-------- .../ketch/app/ui/settings/SettingsPage.kt | 2 +- .../ketch/app/ui/settings/SettingsPageTest.kt | 6 +++ .../ketch/app/snapshot/SettingsSnapshots.kt | 10 ++++ .../settings/AiDiscoverySettingsRenderTest.kt | 48 +++++++++++++++++++ docs/ai-discovery.md | 5 +- 6 files changed, 92 insertions(+), 23 deletions(-) diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt index 60920cf14..e9162d1bc 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettings.kt @@ -108,13 +108,16 @@ import kotlin.time.TimeSource * [state]'s AI settings. Every change is saved as it is made. Changes to the model or search * rebuild the discovery engine; page access and content filter changes keep it, so running * searches carry on, asking by the new rules, and the next search filters by the new setting. - * Test calls the provider with the saved settings. + * Test calls the provider with the saved settings. While discovery is switched off, everything + * but its switch is disabled, keeping what was entered for when it is switched back on. */ @Composable fun AiDiscoverySettings(state: AppState) { val ai = state.aiSettings val settings = ai.settings val supported = ai.supported + // Everything under the switch follows it. + val editable = supported && settings.enabled val connectionTest = ai.connectionTest val onChange = { changed: AiSettings -> ai.save(changed) } val focusManager = LocalFocusManager.current @@ -141,18 +144,18 @@ fun AiDiscoverySettings(state: AppState) { title = stringResource(Res.string.settings_ai_content_filter), description = stringResource(Res.string.settings_ai_content_filter_hint), checked = settings.contentFilter, - enabled = supported, + enabled = editable, onCheckedChange = { onChange(settings.copy(contentFilter = it)) }, ) } - PageAccessGroup(access = settings.access, enabled = supported, onChange = ai::saveAccess) + PageAccessGroup(access = settings.access, enabled = editable, onChange = ai::saveAccess) SettingsGroup(title = stringResource(Res.string.settings_ai_model_group)) { SettingsRow( title = stringResource(Res.string.settings_ai_provider), description = providerHint(llm.provider).resolve(), - enabled = supported, + enabled = editable, ) { FlowRow( modifier = Modifier.fillMaxWidth(), @@ -163,7 +166,7 @@ fun AiDiscoverySettings(state: AppState) { KetchChip( label = provider.buttonLabel.resolve(), selected = provider == llm.provider, - enabled = supported, + enabled = editable, // Model and endpoint are provider-specific, so switching falls back to the new // provider's defaults. The token is kept: clearing a secret on a stray tap is // worse than a token the connection test will reject. @@ -186,7 +189,7 @@ fun AiDiscoverySettings(state: AppState) { } else { stringResource(Res.string.settings_ai_api_key_plain) }, - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = llm.apiKey, @@ -194,7 +197,7 @@ fun AiDiscoverySettings(state: AppState) { placeholder = tokenPlaceholder(llm.provider), secret = true, mono = true, - enabled = supported, + enabled = editable, ) } } @@ -205,7 +208,7 @@ fun AiDiscoverySettings(state: AppState) { } else { stringResource(Res.string.settings_ai_model_any) }, - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = llm.model, @@ -213,7 +216,7 @@ fun AiDiscoverySettings(state: AppState) { placeholder = llm.provider.defaultModel .ifBlank { stringResource(Res.string.settings_ai_model_placeholder) }, mono = true, - enabled = supported, + enabled = editable, ) val suggestions = modelSuggestions(llm.provider) if (suggestions.isNotEmpty()) { @@ -232,7 +235,7 @@ fun AiDiscoverySettings(state: AppState) { KetchButtonVariant.Ghost }, size = KetchButtonSize.Small, - enabled = supported, + enabled = editable, ) } } @@ -250,14 +253,14 @@ fun AiDiscoverySettings(state: AppState) { } else { null }, - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = llm.baseUrl, onCommit = { onChange(settings.copy(llm = llm.copy(baseUrl = it))) }, placeholder = llm.provider.defaultBaseUrl.ifBlank { "https://openrouter.ai/api/v1" }, mono = true, - enabled = supported, + enabled = editable, ) } // How long the last test took, measured from the click. @@ -275,7 +278,7 @@ fun AiDiscoverySettings(state: AppState) { title = stringResource(Res.string.settings_ai_test), description = testMessage.resolve(), descriptionColor = testColor, - enabled = supported, + enabled = editable, trailing = { KetchButton( text = if (testing) { @@ -292,7 +295,7 @@ fun AiDiscoverySettings(state: AppState) { }, variant = KetchButtonVariant.Secondary, size = KetchButtonSize.Small, - enabled = supported && effective.llm.isComplete && !testing, + enabled = editable && effective.llm.isComplete && !testing, ) }, ) @@ -308,13 +311,13 @@ fun AiDiscoverySettings(state: AppState) { value = search.provider, options = SearchProvider.entries, label = { it.displayName }, - enabled = supported, + enabled = editable, onSelect = { onChange(settings.copy(search = search.copy(provider = it))) }, ) if (search.provider.requiresApiKey) { SettingsRow( title = stringResource(Res.string.settings_ai_search_api_key), - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = search.apiKey, @@ -322,7 +325,7 @@ fun AiDiscoverySettings(state: AppState) { placeholder = stringResource(Res.string.settings_ai_api_key), secret = true, mono = true, - enabled = supported, + enabled = editable, ) } } @@ -330,14 +333,14 @@ fun AiDiscoverySettings(state: AppState) { SettingsRow( title = stringResource(Res.string.settings_ai_engine_id), description = stringResource(Res.string.settings_ai_engine_id_hint), - enabled = supported, + enabled = editable, ) { SettingsTextInput( value = search.cx, onCommit = { onChange(settings.copy(search = search.copy(cx = it))) }, placeholder = stringResource(Res.string.settings_ai_engine_id_placeholder), mono = true, - enabled = supported, + enabled = editable, ) } } @@ -506,11 +509,12 @@ private fun discoveryStatus( val colors = KetchTheme.colors return when { !supported -> Res.string.settings_ai_status_unsupported.text() to colors.textTertiary + // Switched off, nothing below can be changed, so it says nothing of what is missing. + !settings.enabled -> Res.string.settings_ai_status_off.text() to colors.textSecondary !effective.llm.isComplete -> Res.string.settings_ai_status_needs_provider.text() to colors.status.paused.color !effective.search.isComplete -> Res.string.settings_ai_status_needs_search.text() to colors.status.paused.color - !settings.enabled -> Res.string.settings_ai_status_off.text() to colors.textSecondary else -> Res.string.settings_ai_status_ready.text( effective.llm.provider.displayName, effective.llm.effectiveModel, diff --git a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt index 4083d925c..ec04b27f8 100644 --- a/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt +++ b/app/shared/src/commonMain/kotlin/com/linroid/ketch/app/ui/settings/SettingsPage.kt @@ -859,9 +859,9 @@ internal fun integrationSummary( * @param effective [settings] with the credentials the platform supplies. */ internal fun discoverSummary(settings: AiSettings, effective: AiSettings): UiText = when { + !settings.enabled -> Res.string.settings_summary_off.text() !effective.llm.isComplete || !effective.search.isComplete -> Res.string.settings_summary_not_set_up.text() - !settings.enabled -> Res.string.settings_summary_off.text() effective.search.provider == SearchProvider.None -> effective.llm.provider.displayName else -> Res.string.settings_summary_discover_search.text( effective.llm.provider.displayName, diff --git a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt index 9b5505f15..394fa6874 100644 --- a/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt +++ b/app/shared/src/commonTest/kotlin/com/linroid/ketch/app/ui/settings/SettingsPageTest.kt @@ -110,6 +110,12 @@ class SettingsPageTest { assertEquals("Not set up", discoverSummary(saved, saved).load()) assertEquals("OpenAI", discoverSummary(saved, effective).load()) } + @Test + fun discoverSummary_switchedOffWithoutKeys_saysOff() = runTest { + val off = AiSettings(enabled = false) + assertEquals("Off", discoverSummary(off, off).load()) + } + @Test fun downloadsSummary_namesFolderAndQueue() = runTest { diff --git a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt index 025e6e0de..c522f188b 100644 --- a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt +++ b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/snapshot/SettingsSnapshots.kt @@ -153,6 +153,16 @@ class SettingsSnapshots { } } + @Test + fun discoverPage_switchedOff_disablesEverythingButTheSwitch() { + withSettings(SnapshotTheme.Light, PhoneTall.density) { + captureApp("settings-discover-off", PhoneTall, SnapshotTheme.Light, it) { + state.aiSettings.setEnabled(false) + openSettings(SettingsTarget.Page.Discover) + } + } + } + @Test fun app_mediumWindow_showsSettingsAsTwoPanes() { for (theme in SnapshotTheme.entries) { diff --git a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt index 066c65887..b16cee31c 100644 --- a/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt +++ b/app/shared/src/jvmTest/kotlin/com/linroid/ketch/app/ui/settings/AiDiscoverySettingsRenderTest.kt @@ -21,6 +21,7 @@ import com.linroid.ketch.config.PageAccessMode import com.linroid.ketch.config.PageAccessSettings import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertSame import kotlin.test.assertTrue @@ -80,6 +81,26 @@ class AiDiscoverySettingsRenderTest { } } + @Test + fun switchOff_disablesEverythingButTheSwitch() { + runDiscoverPage(trusted = listOf("ubuntu.com")) { ai -> + ai.setEnabled(false) + frames(FRAMES) + + assertTrue(field(PLACEHOLDER).isDisabled(), "The page access field follows the switch") + assertTrue(control("Gemini").isDisabled(), "The provider chips follow the switch") + assertTrue(control("Ask for each new site").isDisabled(), "So does the page access menu") + assertFalse(discoverSwitch().isDisabled(), "The switch itself stays usable") + + discoverSwitch().click() + frames(FRAMES) + + assertTrue(ai.settings.enabled) + assertFalse(field(PLACEHOLDER).isDisabled()) + assertEquals(listOf("ubuntu.com"), ai.settings.access.trustedSites, "Nothing is lost") + } + } + /** Renders the Discover page with [trusted] sites and runs [test] on it. */ private fun runDiscoverPage( trusted: List = emptyList(), @@ -108,6 +129,33 @@ class AiDiscoverySettingsRenderTest { SemanticsActions.SetText in node.config && node.subtree().any { it.ownText() == PLACEHOLDER } } + /** + * The field showing [placeholder], usable or not: a disabled field cannot be edited, so it + * has no SetText. + */ + private fun ImageComposeScene.field(placeholder: String): SemanticsNode = nodes().first { node -> + (SemanticsActions.SetText in node.config || SemanticsProperties.Disabled in node.config) && + node.subtree().any { it.ownText() == placeholder } + } + + /** The row that switches discovery on and off. */ + private fun ImageComposeScene.discoverSwitch(): SemanticsNode = nodes().first { node -> + SemanticsProperties.ToggleableState in node.config && + node.subtree().any { it.ownText() == "AI discovery" } + } + + /** The control around the text [label]. */ + private fun ImageComposeScene.control(label: String): SemanticsNode = + checkNotNull(nodes().first { it.ownText() == label }.clickableAround()) + + private fun SemanticsNode.clickableAround(): SemanticsNode? { + var node: SemanticsNode? = this + while (node != null && SemanticsActions.OnClick !in node.config) node = node.parent + return node + } + + private fun SemanticsNode.isDisabled(): Boolean = SemanticsProperties.Disabled in config + /** Puts the keyboard in the add field and types [text] into it. */ private suspend fun ImageComposeScene.typeIntoAddField(text: String) { val field = addField() diff --git a/docs/ai-discovery.md b/docs/ai-discovery.md index 994729346..47d8543ae 100644 --- a/docs/ai-discovery.md +++ b/docs/ai-discovery.md @@ -364,8 +364,9 @@ Rules: The settings page judges the form the way the engine will: a blank API key that the environment supplies is noted under the **API key** field and counts as filled in the status line, and **Test** works with the -token left empty. It also works with the switch off, so you can check a -key before turning discovery on. +token left empty. While the switch is off, everything under it is +disabled and the status line just says so; what you entered is kept for +when you switch it back on. When the provider fails during a search or a **Test**, such as a rejected API key, an unknown model or an unreachable endpoint, Discover shows why