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
This commit is contained in:
Claude
2026-04-08 16:33:06 +00:00
parent 60d817b0c0
commit 6f6e876158
3 changed files with 78 additions and 50 deletions
@@ -26,24 +26,20 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -55,8 +51,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
@Composable @Composable
fun AiWritingHelpPanel( fun AiWritingHelpPanel(
isVisible: Boolean, isVisible: Boolean,
isProcessing: Boolean, readyResults: Map<WritingTone, WritingResult>,
result: WritingResult?, selectedResult: WritingResult?,
onToneSelected: (WritingTone) -> Unit, onToneSelected: (WritingTone) -> Unit,
onApply: () -> Unit, onApply: () -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
@@ -72,19 +68,7 @@ fun AiWritingHelpPanel(
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
) { ) {
if (isProcessing) { selectedResult?.let {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
result?.let {
AiResultCard( AiResultCard(
result = it, result = it,
onApply = onApply, 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 @Composable
private fun ToneChipRow(onToneSelected: (WritingTone) -> Unit) { private fun ToneChipRow(
readyTones: Set<WritingTone>,
selectedTone: WritingTone?,
onToneSelected: (WritingTone) -> Unit,
) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
Row( Row(
modifier = modifier =
@@ -152,11 +144,13 @@ private fun ToneChipRow(onToneSelected: (WritingTone) -> Unit) {
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
WritingTone.entries.forEach { tone -> WritingTone.entries.forEach { tone ->
FilterChip( if (tone in readyTones) {
selected = false, FilterChip(
onClick = { onToneSelected(tone) }, selected = tone == selectedTone,
label = { Text(toneDisplayName(tone)) }, onClick = { onToneSelected(tone) },
) label = { Text(toneDisplayName(tone)) },
)
}
} }
} }
} }
@@ -563,9 +563,9 @@ private fun NewPostScreenBody(
AiWritingHelpPanel( AiWritingHelpPanel(
isVisible = postViewModel.showAiPanel, isVisible = postViewModel.showAiPanel,
isProcessing = postViewModel.isAiProcessing, readyResults = postViewModel.aiResults,
result = postViewModel.aiResult, selectedResult = postViewModel.aiSelectedResult,
onToneSelected = postViewModel::requestAiTransform, onToneSelected = postViewModel::selectAiResult,
onApply = postViewModel::applyAiResult, onApply = postViewModel::applyAiResult,
onDismiss = postViewModel::dismissAiResult, onDismiss = postViewModel::dismissAiResult,
) )
@@ -147,7 +147,12 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers 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.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch 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. // TODO: Remove useMockAi before shipping. Set to true to test UI without Gemini Nano.
private val useMockAi = true private val useMockAi = true
var aiResult by mutableStateOf<WritingResult?>(null) var aiResults by mutableStateOf<Map<WritingTone, WritingResult>>(emptyMap())
var aiSelectedResult by mutableStateOf<WritingResult?>(null)
var aiStatus by mutableStateOf<WritingAssistantStatus>(WritingAssistantStatus.Unavailable) var aiStatus by mutableStateOf<WritingAssistantStatus>(WritingAssistantStatus.Unavailable)
var isAiProcessing by mutableStateOf(false)
private var writingAssistant: WritingAssistant? = null private var writingAssistant: WritingAssistant? = null
private var aiComputeJob: Job? = null
private var lastComputedText: String = ""
val showAiPanel: Boolean val showAiPanel: Boolean
get() { get() {
val prefEnabled = val prefEnabled =
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value == accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
BooleanType.ALWAYS BooleanType.ALWAYS
return prefEnabled && aiStatus is WritingAssistantStatus.Available return prefEnabled && aiStatus is WritingAssistantStatus.Available && aiResults.isNotEmpty()
} }
fun initWritingAssistant(context: android.content.Context) { fun initWritingAssistant(context: android.content.Context) {
@@ -329,35 +336,61 @@ open class ShortNotePostViewModel :
} }
} }
fun requestAiTransform(tone: WritingTone) { fun precomputeAiResults() {
val text = message.text.toString() val assistant = writingAssistant ?: return
if (text.isBlank() || isAiProcessing) return if (aiStatus !is WritingAssistantStatus.Available) return
val prefEnabled =
accountViewModel.settings.uiSettingsFlow.automaticallyProposeAiImprovements.value ==
BooleanType.ALWAYS
if (!prefEnabled) return
isAiProcessing = true val text = message.text.toString().trim()
aiResult = null if (text.isBlank() || text == lastComputedText) return
viewModelScope.launch(Dispatchers.IO) {
try { aiComputeJob?.cancel()
val result = writingAssistant?.transform(text, tone) aiResults = emptyMap()
aiResult = result aiSelectedResult = null
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e aiComputeJob =
aiResult = null viewModelScope.launch(Dispatchers.IO) {
} finally { delay(1000)
isAiProcessing = false 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() { fun applyAiResult() {
aiResult?.let { aiSelectedResult?.let {
message.setTextAndPlaceCursorAtEnd(it.transformedText) message.setTextAndPlaceCursorAtEnd(it.transformedText)
aiResult = null aiSelectedResult = null
aiResults = emptyMap()
lastComputedText = ""
draftTag.newVersion() draftTag.newVersion()
} }
} }
fun dismissAiResult() { fun dismissAiResult() {
aiResult = null aiSelectedResult = null
} }
fun lnAddress(): String? = account.userProfile().lnAddress() fun lnAddress(): String? = account.userProfile().lnAddress()
@@ -1200,6 +1233,7 @@ open class ShortNotePostViewModel :
emojiSuggestions?.processCurrentWord(lastWord) emojiSuggestions?.processCurrentWord(lastWord)
} }
precomputeAiResults()
draftTag.newVersion() draftTag.newVersion()
} }