Detail: inline summarize progress + more voices + custom voice

- Progress bar + 'Summarizing… · <voice> (attempt n/3)' right under the
  Re-summarize button: the top spinner hides behind a long report, which
  made a running job look like nothing happened.
- Voices: + Enthusiastic, + Storyteller, + 'Other…' free-text (any
  phrase, 60 chars; dialog shows how it reads in the prompt).
- Verified end-to-end on BOTS: sarcastic summarize succeeded, summary
  v2 stored with tone=sarcastic ('…a heroic inability to monetize
  anything'). BUILD SUCCESSFUL + 39 tests.
This commit is contained in:
avi 2026-09-15 15:10:39 -05:00
commit 7d3e0e886d

View file

@ -517,12 +517,17 @@ fun LibraryScreen(state: DesktopState) {
/** Personalities offered beside Re-summarize: (label, tone sent to the /** Personalities offered beside Re-summarize: (label, tone sent to the
* engine). The tone string is interpolated into "in a <tone> tone of * engine). The tone string is interpolated into "in a <tone> tone of
* voice", so it must read naturally there. null = neutral house voice. */ * voice", so it must read naturally there. null = neutral house voice.
* CUSTOM_TONE opens a free-text box — the engine accepts any phrase. */
private const val CUSTOM_TONE = "custom"
private val SUMMARIZE_PERSONAS = listOf( private val SUMMARIZE_PERSONAS = listOf(
"Neutral" to null, "Neutral" to null,
"Sarcastic" to "sarcastic", "Sarcastic" to "sarcastic",
"Funny" to "funny", "Funny" to "funny",
"Dry wit" to "dry, witty", "Dry wit" to "dry, witty",
"Enthusiastic" to "enthusiastic",
"Storyteller" to "storyteller",
) )
@androidx.compose.runtime.Composable @androidx.compose.runtime.Composable
@ -538,6 +543,10 @@ fun DetailScreen(state: DesktopState) {
var recQuery by remember { mutableStateOf("") } var recQuery by remember { mutableStateOf("") }
// Voice for the next summarize: null = Neutral (server default). // Voice for the next summarize: null = Neutral (server default).
var sumTone by remember(detail.file.name) { mutableStateOf<String?>(null) } var sumTone by remember(detail.file.name) { mutableStateOf<String?>(null) }
var customTone by remember(detail.file.name) { mutableStateOf("") }
var showCustomDialog by remember(detail.file.name) { mutableStateOf(false) }
val effectiveTone = if (sumTone == CUSTOM_TONE)
customTone.trim().takeIf { it.isNotBlank() } else sumTone
val playingFile by state.playingFile.collectAsState() val playingFile by state.playingFile.collectAsState()
val playError by state.playError.collectAsState() val playError by state.playError.collectAsState()
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
@ -784,20 +793,93 @@ fun DetailScreen(state: DesktopState) {
label = { Text(label) }, label = { Text(label) },
) )
} }
FilterChip(
selected = sumTone == CUSTOM_TONE,
onClick = {
sumTone = CUSTOM_TONE
showCustomDialog = true
},
label = {
Text(if (sumTone == CUSTOM_TONE &&
customTone.isNotBlank())
"“${customTone.trim()}”" else "Other…")
},
)
}
if (showCustomDialog) {
AlertDialog(
onDismissRequest = { showCustomDialog = false },
title = { Text("Any voice you like") },
text = {
Column {
OutlinedTextField(customTone, {
if (it.length <= 60) customTone = it },
label = { Text("e.g. pirate, philosopher, sports caster") },
singleLine = true,
modifier = Modifier.fillMaxWidth())
Text("Used as: “…in a <your words> tone of voice.”",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
confirmButton = {
TextButton({
if (customTone.isNotBlank())
showCustomDialog = false
}) { Text("Use this voice") }
},
dismissButton = {
TextButton({
sumTone = null
customTone = ""
showCustomDialog = false
}) { Text("Cancel") }
},
)
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton({ state.transcribe() }) { TextButton({ state.transcribe() }) {
Text(if (hasTranscript) "Re-transcribe" else "Transcribe") Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
} }
TextButton({ state.summarize(sumTone) }, TextButton({ state.summarize(effectiveTone) },
enabled = remoteKnown && hasTranscript) { enabled = remoteKnown && hasTranscript &&
!(sumTone == CUSTOM_TONE &&
customTone.isBlank())) {
Text(buildString { Text(buildString {
append(if (hasSummary) "Re-summarize" else "Summarize") append(if (hasSummary) "Re-summarize" else "Summarize")
sumTone?.let { append(" · $it") } effectiveTone?.let { append(" · $it") }
}) })
} }
} }
// Progress right where the buttons are: the busy spinner near
// the top is off-screen once a report fills the page, which
// made a running summarize look like "nothing happens".
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
val sRunning = sJob?.status in setOf("queued", "running")
if (sRunning) {
val sStatus = sJob?.status
val sProg = sJob?.progress
val sAttempt = sJob?.attempt ?: 0
if (sStatus == "running" && sProg != null && sProg > 0) {
LinearProgressIndicator(
progress = { sProg.coerceIn(0, 99) / 100f },
modifier = Modifier.fillMaxWidth())
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Text(buildString {
append(when {
sStatus == "queued" -> "Queued…"
sProg != null && sProg > 0 -> "Summarizing… $sProg%"
else -> "Summarizing…"
})
effectiveTone?.let { append(" · $it") }
if (sAttempt > 0)
append(" (attempt $sAttempt/${sJob?.maxAttempts})")
}, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary)
}
if (!remoteKnown || !hasTranscript) { if (!remoteKnown || !hasTranscript) {
Text(if (!hasTranscript) Text(if (!hasTranscript)
"Summarize unlocks once this recording has a transcript." "Summarize unlocks once this recording has a transcript."