From 6f6e8761585f04d31c46f828a373e001a3f6fb7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 16:33:06 +0000 Subject: [PATCH] feat: precompute all AI tone results in parallel Instead of computing on-tap, all 9 tones are precomputed in parallel after a 1-second debounce when the user stops typing. Tone chips only appear once results are ready. Tapping a chip instantly shows the precomputed result. Text changes cancel and restart the computation. https://claude.ai/code/session_01RbCYGrbbapRMike8WQy41F --- .../creators/aihelp/AiWritingHelpPanel.kt | 46 +++++------ .../loggedIn/home/ShortNotePostScreen.kt | 6 +- .../loggedIn/home/ShortNotePostViewModel.kt | 76 ++++++++++++++----- 3 files changed, 78 insertions(+), 50 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt index 1d93cc6ed..6c37b1ecb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/aihelp/AiWritingHelpPanel.kt @@ -26,24 +26,20 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilterChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp @@ -55,8 +51,8 @@ import com.vitorpamplona.amethyst.ui.stringRes @Composable fun AiWritingHelpPanel( isVisible: Boolean, - isProcessing: Boolean, - result: WritingResult?, + readyResults: Map, + selectedResult: WritingResult?, onToneSelected: (WritingTone) -> Unit, onApply: () -> Unit, onDismiss: () -> Unit, @@ -72,19 +68,7 @@ fun AiWritingHelpPanel( .fillMaxWidth() .padding(horizontal = 8.dp), ) { - if (isProcessing) { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp)) - } - } - - result?.let { + selectedResult?.let { AiResultCard( result = it, onApply = onApply, @@ -92,7 +76,11 @@ fun AiWritingHelpPanel( ) } - ToneChipRow(onToneSelected = onToneSelected) + ToneChipRow( + readyTones = readyResults.keys, + selectedTone = selectedResult?.tone, + onToneSelected = onToneSelected, + ) } } } @@ -141,7 +129,11 @@ private fun AiResultCard( } @Composable -private fun ToneChipRow(onToneSelected: (WritingTone) -> Unit) { +private fun ToneChipRow( + readyTones: Set, + selectedTone: WritingTone?, + onToneSelected: (WritingTone) -> Unit, +) { val scrollState = rememberScrollState() Row( modifier = @@ -152,11 +144,13 @@ private fun ToneChipRow(onToneSelected: (WritingTone) -> Unit) { horizontalArrangement = Arrangement.spacedBy(6.dp), ) { WritingTone.entries.forEach { tone -> - FilterChip( - selected = false, - onClick = { onToneSelected(tone) }, - label = { Text(toneDisplayName(tone)) }, - ) + if (tone in readyTones) { + FilterChip( + selected = tone == selectedTone, + onClick = { onToneSelected(tone) }, + label = { Text(toneDisplayName(tone)) }, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 3758b8d01..4816efd86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -563,9 +563,9 @@ private fun NewPostScreenBody( AiWritingHelpPanel( isVisible = postViewModel.showAiPanel, - isProcessing = postViewModel.isAiProcessing, - result = postViewModel.aiResult, - onToneSelected = postViewModel::requestAiTransform, + readyResults = postViewModel.aiResults, + selectedResult = postViewModel.aiSelectedResult, + onToneSelected = postViewModel::selectAiResult, onApply = postViewModel::applyAiResult, onDismiss = postViewModel::dismissAiResult, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index fd0a25e32..9ce53a7ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -147,7 +147,12 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -302,17 +307,19 @@ open class ShortNotePostViewModel : // TODO: Remove useMockAi before shipping. Set to true to test UI without Gemini Nano. private val useMockAi = true - var aiResult by mutableStateOf(null) + var aiResults by mutableStateOf>(emptyMap()) + var aiSelectedResult by mutableStateOf(null) var aiStatus by mutableStateOf(WritingAssistantStatus.Unavailable) - var isAiProcessing by mutableStateOf(false) private var writingAssistant: WritingAssistant? = null + private var aiComputeJob: Job? = null + private var lastComputedText: String = "" val showAiPanel: Boolean get() { val prefEnabled = accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value == BooleanType.ALWAYS - return prefEnabled && aiStatus is WritingAssistantStatus.Available + return prefEnabled && aiStatus is WritingAssistantStatus.Available && aiResults.isNotEmpty() } fun initWritingAssistant(context: android.content.Context) { @@ -329,35 +336,61 @@ open class ShortNotePostViewModel : } } - fun requestAiTransform(tone: WritingTone) { - val text = message.text.toString() - if (text.isBlank() || isAiProcessing) return + fun precomputeAiResults() { + val assistant = writingAssistant ?: return + if (aiStatus !is WritingAssistantStatus.Available) return + val prefEnabled = + accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value == + BooleanType.ALWAYS + if (!prefEnabled) return - isAiProcessing = true - aiResult = null - viewModelScope.launch(Dispatchers.IO) { - try { - val result = writingAssistant?.transform(text, tone) - aiResult = result - } catch (e: Exception) { - if (e is kotlinx.coroutines.CancellationException) throw e - aiResult = null - } finally { - isAiProcessing = false + val text = message.text.toString().trim() + if (text.isBlank() || text == lastComputedText) return + + aiComputeJob?.cancel() + aiResults = emptyMap() + aiSelectedResult = null + + aiComputeJob = + viewModelScope.launch(Dispatchers.IO) { + delay(1000) + lastComputedText = text + + coroutineScope { + WritingTone.entries + .map { tone -> + async { + try { + assistant.transform(text, tone) + } catch (e: Exception) { + if (e is CancellationException) throw e + null + } + } + }.forEach { deferred -> + val result = deferred.await() ?: return@forEach + aiResults = aiResults + (result.tone to result) + } + } } - } + } + + fun selectAiResult(tone: WritingTone) { + aiSelectedResult = aiResults[tone] } fun applyAiResult() { - aiResult?.let { + aiSelectedResult?.let { message.setTextAndPlaceCursorAtEnd(it.transformedText) - aiResult = null + aiSelectedResult = null + aiResults = emptyMap() + lastComputedText = "" draftTag.newVersion() } } fun dismissAiResult() { - aiResult = null + aiSelectedResult = null } fun lnAddress(): String? = account.userProfile().lnAddress() @@ -1200,6 +1233,7 @@ open class ShortNotePostViewModel : emojiSuggestions?.processCurrentWord(lastWord) } + precomputeAiResults() draftTag.newVersion() }