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.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<WritingTone, WritingResult>,
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<WritingTone>,
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)) },
)
}
}
}
}
@@ -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,
)
@@ -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<WritingResult?>(null)
var aiResults by mutableStateOf<Map<WritingTone, WritingResult>>(emptyMap())
var aiSelectedResult by mutableStateOf<WritingResult?>(null)
var aiStatus by mutableStateOf<WritingAssistantStatus>(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()
}