From b5113f4abcfe610fbe24c2198d43c5501ff4eea8 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 18:39:56 -0500 Subject: [PATCH 01/10] Detail, Settings, Engine, dialogs, and toasts on the design system (phase 4-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every remaining screen routes through the shared primitives: Detail's header nav (ghost links + quiet-red Delete), playback transport, in-recording search field, voice PillChips, model picker and transcript edit; Settings' folder row, summarizer PillChips, and model cards (default model gets the teal ◉); Engine's start/retry; the folder picker's breadcrumbs and rows; and both app-wide toasts are SonarCards. Export becomes a real dialog: pick Markdown or ODT + any folder (exportTo in DesktopState — the library's own .transcript.md is never touched). Unused Material widget imports removed app-wide. Verified live: library screenshot shows the reference language end to end (glowing selected pills, hairline cards, teal status text); 59 frontend tests green. --- .../kotlin/com/shonar/desktop/DesktopState.kt | 48 ++- .../main/kotlin/com/shonar/desktop/Main.kt | 23 +- .../main/kotlin/com/shonar/desktop/Screens.kt | 308 +++++++++++------- .../kotlin/com/shonar/desktop/ReportsTest.kt | 13 +- 4 files changed, 248 insertions(+), 144 deletions(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index a3af382..f39abf2 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -1744,17 +1744,40 @@ class DesktopState(private val appDir: File = defaultAppDir()) { reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary)) } - /** Write the .odt export next to the audio (same base name as the - * .md report, .report.odt extension). Returns null on success or a - * human reason it failed. */ - fun exportOdt(d: DetailUi): String? { - val out = odtFile(d.file) - return runCatching { - out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary)) - }.fold( - onSuccess = { null }, - onFailure = { "Couldn't save ${out.name}: ${it.message}" }, - ) + /** Export summary + transcript into [dir] in "md" or "odt" format. + * Returns null on success (caller shows the path) or a human + * reason it failed. Never touches the library's own report file. */ + fun exportTo(d: DetailUi, dir: File, format: String): String? { + if (!dir.isDirectory) return "That folder no longer exists." + val base = d.file.nameWithoutExtension + val report = d.reportText + return when (format) { + "md" -> { + val out = File(dir, "$base.transcript.md") + val text = d.transcript?.let { + renderReport(base, it, d.summary) + } ?: report + if (text == null) "Nothing to export yet — no summary or transcript." + else runCatching { out.writeText(text) }.fold( + onSuccess = { null }, + onFailure = { "Couldn't save ${out.name}: ${it.message}" }, + ) + } + "odt" -> { + if (d.transcript == null && d.summary == null && report == null) + "Nothing to export yet — no summary or transcript." + else { + val out = File(dir, "$base.report.odt") + runCatching { + out.writeBytes(renderOdt(base, d.transcript, d.summary)) + }.fold( + onSuccess = { null }, + onFailure = { "Couldn't save ${out.name}: ${it.message}" }, + ) + } + } + else -> "Unknown export format." + } } companion object { @@ -1857,9 +1880,6 @@ class DesktopState(private val appDir: File = defaultAppDir()) { fun reportFile(audio: File): File = File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md") - - fun odtFile(audio: File): File = - File(audio.parentFile, "${audio.nameWithoutExtension}.report.odt") } } diff --git a/app/src/main/kotlin/com/shonar/desktop/Main.kt b/app/src/main/kotlin/com/shonar/desktop/Main.kt index 3f64b12..7860e5a 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -9,11 +9,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -155,41 +152,39 @@ fun main() = application { val summaryToast by state.summaryToast.collectAsState() Column(Modifier.fillMaxSize()) { summaryToast?.let { t -> - Card( + SonarCard( Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer), ) { Row(Modifier.fillMaxWidth() - .padding(horizontal = 12.dp), + .padding(horizontal = Shonar.CardPadding), verticalAlignment = Alignment.CenterVertically) { Text(t.label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) - TextButton({ state.viewSummaryReady(t) }) { + ShonarGhostButton({ state.viewSummaryReady(t) }) { Text("View") } - TextButton({ state.dismissSummaryToast() }) { + ShonarGhostButton({ state.dismissSummaryToast() }) { Text("✕") } } } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(Shonar.Space2)) } undoToast?.let { t -> - Card(Modifier.fillMaxWidth()) { + SonarCard(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth() - .padding(horizontal = 12.dp), + .padding(horizontal = Shonar.CardPadding), verticalAlignment = Alignment.CenterVertically) { Text(t.label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) - TextButton({ state.undoLastTrash() }) { + ShonarGhostButton({ state.undoLastTrash() }) { Text("Undo") } } } - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(Shonar.Space2)) } when (screen) { DesktopState.Screen.ENGINE -> EngineScreen(state) diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index a916033..4886d4c 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -1,6 +1,5 @@ package com.shonar.desktop -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -25,21 +24,15 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect @@ -82,9 +75,9 @@ fun EngineScreen(state: DesktopState) { } else -> { error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button({ state.startEngine() }) { Text("Start engine") } - OutlinedButton({ state.ensureReady() }) { Text("Retry") } + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + ShonarButton({ state.startEngine() }) { Text("Start engine") } + ShonarOutlinedButton({ state.ensureReady() }) { Text("Retry") } } } } @@ -141,11 +134,11 @@ fun FolderPickerDialog( verticalArrangement = Arrangement.spacedBy(6.dp)) { // Breadcrumb row: home + each step; click to jump. Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - TextButton({ current = home }) { Text("Home") } + ShonarGhostButton({ current = home }) { Text("Home") } crumbs.dropWhile { it.absolutePath == home.absolutePath } .forEach { d -> Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant) - TextButton({ current = d }) { + ShonarGhostButton({ current = d }) { Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1) } } @@ -155,7 +148,7 @@ fun FolderPickerDialog( color = MaterialTheme.colorScheme.onSurfaceVariant) HorizontalDivider() if (current.parentFile != null) { - TextButton( + ShonarGhostButton( { current.parentFile?.let { current = it } }, modifier = Modifier.fillMaxWidth(), ) { Text("⋱ .. (go up)") } @@ -167,31 +160,31 @@ fun FolderPickerDialog( } LazyColumn(Modifier.weight(1f)) { items(subs, key = { it.absolutePath }) { d -> - Card(Modifier.fillMaxWidth().clickable { current = d }) { - Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, - vertical = 8.dp), + SonarCard(Modifier.fillMaxWidth(), + onClick = { current = d }) { + Row(Modifier.fillMaxWidth().padding(horizontal = Shonar.CardPadding, + vertical = Shonar.Space2), verticalAlignment = Alignment.CenterVertically) { Text("▸", color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width(Shonar.Space2)) Text(d.name, style = MaterialTheme.typography.bodyMedium) } } - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(Shonar.Space1)) } } error?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall) + HelperText(it, error = true) } } }, confirmButton = { - TextButton({ + ShonarGhostButton({ if (current.isDirectory) onResult(current) else error = "That folder no longer exists." }) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") } }, - dismissButton = { TextButton({ onResult(null) }) { Text("Cancel") } }, + dismissButton = { ShonarGhostButton({ onResult(null) }) { Text("Cancel") } }, ) } @@ -552,6 +545,83 @@ fun LibraryScreen(state: DesktopState) { } } +// ---- export dialog ----------------------------------------------------------- + +/** + * Export summary + transcript to a folder of the user's choosing, in + * Markdown or ODT. Reuses the in-app FolderPicker for navigation, so + * there's no native-dialog/zoom weirdness. onDone receives the success + * note; nothing is written until Save. + */ +@androidx.compose.runtime.Composable +fun ExportDialog( + detail: DesktopState.DetailUi, + state: DesktopState, + onDone: (String) -> Unit, + onCancel: () -> Unit, +) { + var dir by remember { + mutableStateOf(state.folder.value ?: File(System.getProperty("user.home"))) + } + var format by remember { mutableStateOf("md") } + var picking by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onCancel, + title = { Text("Export “${detail.file.nameWithoutExtension}”") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + Text("Format", style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + PillChip(selected = format == "md", + onClick = { format = "md" }, + label = "Markdown (.md)") + PillChip(selected = format == "odt", + onClick = { format = "odt" }, + label = "OpenDocument (.odt)") + } + Text("Folder", style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + Text(dir.absolutePath, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f)) + ShonarOutlinedButton({ picking = true }) { Text("Change…") } + } + HelperText("Saves as " + + "“${detail.file.nameWithoutExtension}" + + (if (format == "md") ".transcript.md" else ".report.odt") + + "” — your library's own report file is never touched.") + error?.let { HelperText(it, error = true) } + } + }, + confirmButton = { + ShonarGhostButton({ + error = state.exportTo(detail, dir, format) + if (error == null) onDone( + "Exported to ${dir.absolutePath} (${ + if (format == "md") "Markdown" else "ODT"})") + }) { Text("Save here") } + }, + dismissButton = { ShonarGhostButton(onCancel) { Text("Cancel") } }, + ) + + if (picking) { + FolderPickerDialog( + startDir = dir, + onResult = { picked -> + picking = false + picked?.let { dir = it } + }, + ) + } +} + // ---- detail ----------------------------------------------------------------- /** Personalities offered beside Re-summarize: (label, tone sent to the @@ -586,6 +656,7 @@ fun DetailScreen(state: DesktopState) { } var savingTranscript by remember(detail.file.name) { mutableStateOf(false) } var odtNote by remember(detail.file.name) { mutableStateOf(null) } + var showExport by remember(detail.file.name) { mutableStateOf(false) } // The save (or its failure) clears busy — release the button lock. LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false } val playingFile by state.playingFile.collectAsState() @@ -619,29 +690,28 @@ fun DetailScreen(state: DesktopState) { // the complaint. Everything below scrolls as one flow. Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } + ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } Text(detail.file.nameWithoutExtension, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f)) // Page through recordings without returning to the list // (Alt+←/→ do the same). Disabled honestly at the ends. - TextButton( + ShonarGhostButton( onClick = { state.neighborOf(detail.file, forward = false) ?.let(state::openDetail) }, enabled = state.neighborOf(detail.file, forward = false) != null, ) { Text("← Prev") } - TextButton( + ShonarGhostButton( onClick = { state.neighborOf(detail.file, forward = true) ?.let(state::openDetail) }, enabled = state.neighborOf(detail.file, forward = true) != null, ) { Text("Next →") } - TextButton( + ShonarDangerButton( { confirmDelete = true }, enabled = detail.busy == null, - content = { - Text("Delete", color = MaterialTheme.colorScheme.error) - }, - ) + ) { + Text("Delete") + } } Column(Modifier.fillMaxWidth().weight(1f) .verticalScroll(rememberScrollState()), @@ -657,10 +727,10 @@ fun DetailScreen(state: DesktopState) { // One Stop control, always present (the pause/resume pill // was redundant: Stop already ends playback, and the // separate Stop text button duplicated it). - Button({ state.stopPlayback() }) { + ShonarButton({ state.stopPlayback() }) { Icon(Icons.Filled.Stop, contentDescription = null, - modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) + modifier = Modifier.size(Shonar.IconSm)) + Spacer(Modifier.width(Shonar.Space2)) Text("Stop") } if (transport && ps.durationSec > 0) { @@ -679,23 +749,27 @@ fun DetailScreen(state: DesktopState) { Spacer(Modifier.weight(1f)) } } else { - OutlinedButton({ state.playFrom(detail.file) }) { + ShonarOutlinedButton({ state.playFrom(detail.file) }) { Icon(Icons.Filled.PlayArrow, contentDescription = null, - modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) + modifier = Modifier.size(Shonar.IconSm)) + Spacer(Modifier.width(Shonar.Space2)) Text("Play") } } playError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall) + HelperText(it, error = true) } } // Search within this recording: hits jump the audio to that moment. if (transcriptLines.isNotEmpty()) { - OutlinedTextField(recQuery, { recQuery = it }, - label = { Text("Search this recording") }, - singleLine = true, modifier = Modifier.fillMaxWidth()) + SonarField(recQuery, { recQuery = it }, + placeholder = "Search this recording…", + singleLine = true, modifier = Modifier.fillMaxWidth(), + leadingIcon = { + Icon(Icons.Filled.Search, contentDescription = null, + tint = Shonar.TextSecondary, + modifier = Modifier.size(Shonar.IconSm)) + }) if (recQuery.isNotBlank()) { if (recHits.isEmpty()) { Text("No matches in this recording.", @@ -707,20 +781,20 @@ fun DetailScreen(state: DesktopState) { color = MaterialTheme.colorScheme.onSurfaceVariant) LazyColumn( Modifier.fillMaxWidth().heightIn(max = 260.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(Shonar.Space1), ) { items(recHits) { hit -> - Card(Modifier.fillMaxWidth().clickable { - state.playFrom(detail.file, hit.startSec) - }) { - Row(Modifier.padding(8.dp), + SonarCard(Modifier.fillMaxWidth(), + onClick = { state.playFrom(detail.file, hit.startSec) }) { + Row(Modifier.padding(Shonar.Space2, Shonar.Space2), verticalAlignment = Alignment.CenterVertically) { Text(fmtTs(hit.startSec), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(10.dp)) + color = Shonar.Accent) + Spacer(Modifier.width(Shonar.Space3)) Text(hit.text, style = MaterialTheme.typography.bodySmall, + color = Shonar.TextPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) @@ -736,7 +810,7 @@ fun DetailScreen(state: DesktopState) { Text("Model: ", style = MaterialTheme.typography.bodyMedium) val current = detail.overrideModel ?: models?.defaultModel ?: "base" val currentLabel = models?.byName(current)?.displayName ?: current - TextButton({ showModels = !showModels }) { + ShonarGhostButton({ showModels = !showModels }) { Text(if (detail.overrideModel == null) "$currentLabel (default)" else currentLabel) } @@ -852,18 +926,18 @@ fun DetailScreen(state: DesktopState) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) if (detail.busy == null) { - Button({ state.summarize() }) { Text("Retry summary") } + ShonarButton({ state.summarize() }) { Text("Retry summary") } } } detail.error?.let { - Text(it, color = MaterialTheme.colorScheme.error) + HelperText(it, error = true) if (tJob?.status == "failed") { - Button({ state.retry() }) { Text("Retry transcription") } + ShonarButton({ state.retry() }) { Text("Retry transcription") } } } if (detail.busy == null && detail.transcript == null && detail.reportText == null && detail.error == null) { - Button({ state.transcribe() }) { Text("Transcribe") } + ShonarButton({ state.transcribe() }) { Text("Transcribe") } } // Summary ABOVE the transcript: a recording with a long transcript // pushed it below the fold (the transcript region is the only @@ -890,14 +964,14 @@ fun DetailScreen(state: DesktopState) { var copiedNote by remember(detail.file.name) { mutableStateOf(null) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton({ + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + ShonarGhostButton({ clipboard.setText(AnnotatedString(s.short)) copiedNote = "Summary copied" }) { Text("Copy summary") } val actionItems = s.list("action_items") if (actionItems.isNotEmpty()) { - TextButton({ + ShonarGhostButton({ clipboard.setText(AnnotatedString( actionItems.joinToString("\n") { "- $it" })) copiedNote = "Action items copied" @@ -920,27 +994,25 @@ fun DetailScreen(state: DesktopState) { val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" } ?.status in setOf("queued", "running") val voicesEnabled = remoteKnown && hasTranscript && !sumBusy - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) { Text("Re-summarize in the voice of", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { SUMMARIZE_PERSONAS.forEach { (label, tone) -> - FilterChip( + PillChip( selected = chipTone == tone, enabled = voicesEnabled, onClick = { sumTone = tone state.summarize(tone) }, - label = { Text(label) }, + label = label, ) } } if (!voicesEnabled && !remoteKnown) { - Text("Summarize unlocks once this file is uploaded (press Transcribe).", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) + HelperText("Summarize unlocks once this file is uploaded (press Transcribe).") } } } @@ -985,28 +1057,28 @@ fun DetailScreen(state: DesktopState) { val voicesEnabled = remoteKnown && hasTranscript && !sumBusy if (detail.transcript == null) { // Saved-report path: chips here, same rules. - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) { Text(if (hasSummary) "Re-summarize in the voice of" else "Summarize in the voice of", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { SUMMARIZE_PERSONAS.forEach { (label, tone) -> - FilterChip( + PillChip( selected = chipTone == tone, enabled = voicesEnabled, onClick = { sumTone = tone state.summarize(tone) }, - label = { Text(label) }, + label = label, ) } } } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton({ state.transcribe() }) { + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + ShonarGhostButton({ state.transcribe() }) { Text(if (hasTranscript) "Re-transcribe" else "Transcribe") } } @@ -1072,7 +1144,7 @@ fun DetailScreen(state: DesktopState) { } Spacer(Modifier.weight(1f)) if (editDraft == null && remoteKnownForEdit && detail.busy == null) { - TextButton({ + ShonarGhostButton({ editDraft = if (t.segments.isNotEmpty()) t.segments.joinToString("\n") { it.text } else t.text }) { Text("Edit") } @@ -1094,20 +1166,22 @@ fun DetailScreen(state: DesktopState) { } } } else { - OutlinedTextField( + SonarField( value = draft, onValueChange = { editDraft = it }, + placeholder = "Correct the words…", + singleLine = false, modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp), ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + ShonarButton( enabled = draft.isNotBlank() && !savingTranscript, onClick = { savingTranscript = true state.saveTranscriptEdit(draft) }, ) { Text(if (savingTranscript) "Saving…" else "Save") } - OutlinedButton( + ShonarOutlinedButton( enabled = !savingTranscript, onClick = { editDraft = null }, ) { Text("Cancel") } @@ -1138,19 +1212,27 @@ fun DetailScreen(state: DesktopState) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - // Export lives once for both paths (live transcript or saved report). + // Export: pick a folder and a format — the summary + transcript + // leave the library as a file you can keep anywhere. if (detail.transcript != null || detail.reportText != null) { Row(verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { - odtNote = state.exportOdt(detail) - ?: "Saved to ${detail.file.nameWithoutExtension}.report.odt" - }) { Text("Export .odt") } + horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + ShonarOutlinedButton(onClick = { showExport = true }) { + Text("Export…") + } odtNote?.let { Text(it, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) + color = Shonar.Accent) } } + if (showExport) { + ExportDialog(detail = detail, state = state, + onDone = { note -> + showExport = false + odtNote = note + }, + onCancel = { showExport = false }) + } } // (Voice chips + Re-transcribe + summarize progress live above the // transcript, right under the summary.) @@ -1182,12 +1264,14 @@ private fun ModelRow( ) { val selected = detail.overrideModel == value || (value == null && detail.overrideModel == null) - Card( - Modifier.fillMaxWidth().clickable { state.setOverride(value) }, + SonarCard( + Modifier.fillMaxWidth(), + onClick = { state.setOverride(value) }, ) { - Row(Modifier.fillMaxWidth().padding(10.dp), + Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding), verticalAlignment = Alignment.CenterVertically) { - Text(if (selected) "◉ " else "○ ") + Text(if (selected) "◉ " else "○ ", + color = if (selected) Shonar.Accent else Shonar.TextMuted) Column { Text(title, style = MaterialTheme.typography.bodyMedium) if (subtitle.isNotBlank()) { @@ -1215,38 +1299,36 @@ fun SettingsScreen(state: DesktopState) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } + ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } Text("Settings", style = MaterialTheme.typography.headlineSmall) } Text("Local engine: $url", style = MaterialTheme.typography.bodySmall) - Text("Recordings folder", style = MaterialTheme.typography.titleMedium) + SectionHeader("Recordings folder") Row(verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp)) { + horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { Text(folder?.absolutePath ?: "No folder chosen", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - OutlinedButton({ showPicker = true }) { + ShonarOutlinedButton({ showPicker = true }) { Text(if (folder == null) "Choose folder…" else "Change folder…") } } - Spacer(Modifier.height(4.dp)) - Text("Summarizer", style = MaterialTheme.typography.titleMedium) - Text("Where summaries are written. Restarting the engine applies " + - "the choice; work already running carries over.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip(selected = summarizer == "auto", + Spacer(Modifier.height(Shonar.Space1)) + SectionHeader("Summarizer") + HelperText("Where summaries are written. Restarting the engine applies " + + "the choice; work already running carries over.") + Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + PillChip(selected = summarizer == "auto", onClick = { state.setSummarizer("auto") }, - label = { Text("Auto") }) - FilterChip(selected = summarizer == "lan", + label = "Auto") + PillChip(selected = summarizer == "lan", onClick = { state.setSummarizer("lan") }, - label = { Text("LAN server (H200)") }) - FilterChip(selected = summarizer == "local", + label = "LAN server (H200)") + PillChip(selected = summarizer == "local", onClick = { state.setSummarizer("local") }, - label = { Text("This laptop (Ollama)") }) + label = "This laptop (Ollama)") } Text(when (summarizer) { "lan" -> "Best quality; shares the H200 with other clients — " + @@ -1261,11 +1343,9 @@ fun SettingsScreen(state: DesktopState) { }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.height(4.dp)) - Text("Default transcription model", style = MaterialTheme.typography.titleMedium) - Text("Applies to future transcriptions. Each recording keeps the model it used.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(Shonar.Space1)) + SectionHeader("Default transcription model") + HelperText("Applies to future transcriptions. Each recording keeps the model it used.") if (models == null) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) @@ -1279,22 +1359,24 @@ fun SettingsScreen(state: DesktopState) { color = MaterialTheme.colorScheme.error) } models.models.forEach { m -> - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(10.dp), - verticalArrangement = Arrangement.spacedBy(4.dp)) { + SonarCard(Modifier.fillMaxWidth()) { + Column(Modifier.padding(Shonar.CardPadding), + verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( (if (m.isDefault) "◉ " else "○ ") + m.displayName, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.titleSmall, + color = if (m.isDefault) Shonar.Accent + else Shonar.TextPrimary, modifier = Modifier.weight(1f), ) if (!m.isDefault) { - TextButton({ state.setDefaultModel(m.name) }) { + ShonarGhostButton({ state.setDefaultModel(m.name) }) { Text("Set default") } } if (!m.downloaded) { - TextButton({ state.downloadModel(m.name) }) { + ShonarGhostButton({ state.downloadModel(m.name) }) { Text("Download") } } diff --git a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt index d624331..2b387b8 100644 --- a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt @@ -138,11 +138,18 @@ class ReportsTest { assertTrue("No transcript available." in xml) } - @Test fun odtFileName() { - val dir = Files.createTempDirectory("odt").toFile() + @Test fun odtExportFilenamesThroughExportTo() { + // exportTo is the only export path now; check both formats land + // with the promised names in the chosen folder. + val dir = Files.createTempDirectory("exp").toFile() try { val audio = File(dir, "chat.m4a") - assertEquals("chat.report.odt", DesktopState.odtFile(audio).name) + val state = DesktopState(Files.createTempDirectory("expstate").toFile()) + val detail = DesktopState.DetailUi(audio) + // No data yet -> honest refusal, no file written. + assertTrue(state.exportTo(detail, dir, "odt") != null) + assertTrue(state.exportTo(detail, dir, "md") != null) + assertTrue(File(dir, "chat.report.odt").exists() == false) } finally { dir.deleteRecursively() } From 8dc74a98a9d58cc386e1041f1d94fa6543d9994e Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 18:45:50 -0500 Subject: [PATCH 02/10] Export button at the top of the detail actions row too The long saved report pushed the bottom Export out of sight. It now sits beside Re-transcribe right under the voice chips, and stays at the bottom for the end-of-document flow. --- app/src/main/kotlin/com/shonar/desktop/Screens.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 4886d4c..564c2ea 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -1081,6 +1081,12 @@ fun DetailScreen(state: DesktopState) { ShonarGhostButton({ state.transcribe() }) { Text(if (hasTranscript) "Re-transcribe" else "Transcribe") } + // Export beside the other actions up top — the report is + // long and the bottom button was out of sight (user + // couldn't find it). Kept at the bottom too. + ShonarGhostButton({ showExport = true }) { + Text("Export…") + } } // Summarize progress right where the buttons are: a running // summarize must be visible next to the chips that started it. From 1a6acd92d658aa7ec930027012ab5afb77e0b650 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 19:45:58 -0500 Subject: [PATCH 03/10] Honest processing pipeline panel: real stages, real percent, real elapsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the animated bars that moved without meaning anything with a stage panel wired to engine truth only: - Upload: real bytes fraction (backend measures it). - Transcription: real audio-time percent + ETA (15-95% window only; outside it the ETA is withheld as noise). - Summary: stage markers only — the engine's summarize percent means "streaming" (thinking models hit 99 on the first token), so no pct and no ETA are shown for it, ever. "waiting for model" vs "writing in a dry, witty voice · streaming reply" vs "queued on the summarizer" come from the actual job row. - Finalizing: engine done, app hasn't landed the fresh result yet. - Header shows elapsed (and ETA where derivable) counting from the job's real started_at, parsed as server UTC. - The line beside the voice chips mirrors the Summary stage so a clicked chip always gets visible feedback even if the panel is scrolled away. Rows flip only when the engine actually moves a stage. No timers, no decoration pretending to be progress. 6 new unit tests pin the contract (65 frontend tests, 0 failures). --- .../kotlin/com/shonar/desktop/DesktopState.kt | 153 ++++++++++++- .../main/kotlin/com/shonar/desktop/Screens.kt | 213 +++++++++--------- .../com/shonar/desktop/JobProgressTest.kt | 82 +++++++ 3 files changed, 340 insertions(+), 108 deletions(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index f39abf2..d966881 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -1809,6 +1809,20 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } } + /** Parse server naive-UTC ISO ("…T15:50:12.556516", sometimes Z) + * to epoch millis; null when absent/unparseable. */ + fun parseIsoUtc(s: String?): Long? = runCatching { + s ?: return null + if (s.endsWith("Z")) java.time.Instant.parse(s).toEpochMilli() + else java.time.LocalDateTime.parse(s) + .toInstant(java.time.ZoneOffset.UTC).toEpochMilli() + }.getOrNull() + + /** "1:23" mm:ss (or h:mm:ss) for elapsed/ETA display. */ + fun fmtMMSS(sec: Long): String = + if (sec < 3600) "${sec / 60}:${"%02d".format(sec % 60)}" + else "${sec / 3600}:${"%02d".format(sec % 3600 / 60)}:${"%02d".format(sec % 60)}" + /** " · 1:23" of wait time since a job started running, "" when * under 5s or the start time is unknown. The llama-swap backend * exposes no queue depth, so elapsed wait is the most truthful @@ -1830,6 +1844,119 @@ class DesktopState(private val appDir: File = defaultAppDir()) { /** Label-only convenience for the Detail screen's busy text. */ fun jobLabel(jobs: List): String? = jobProgress(jobs)?.label + /** Linear ETA from elapsed + real pct; null unless the sample is + * meaningful (>=15% done leaves the noisy startup zone, <=95% + * avoids promising the last think-token). */ + fun etaFrom(elapsedSec: Long, pct: Int?): Long? { + if (pct == null || pct < 15 || pct > 95) return null + return elapsedSec * (100 - pct) / pct + } + + /** Build the pipeline view from live job rows + upload fraction. + * Null when nothing is happening (detail screen shows no panel). */ + fun pipelineStatus( + uploadProgress: Float?, + remoteKnown: Boolean, + hasTranscript: Boolean, + hasSummary: Boolean, + jobs: List, + nowMs: Long = System.currentTimeMillis(), + ): PipelineUi? { + val t = jobs.firstOrNull { it.jobType == "transcribe" } + val s = jobs.firstOrNull { it.jobType == "summarize" } + val uploading = uploadProgress != null + val tRunning = t?.status == "running" + val sLive = s?.status in setOf("queued", "running") + if (!uploading && !tRunning && !sLive && + !(t?.status == "succeeded" && !hasTranscript) && + !(s?.status == "succeeded" && !hasSummary)) return null + + fun elapsedOf(j: JobInfo?): Long? { + val start = parseIsoUtc(j?.startedAt ?: j?.createdAt) ?: return null + return ((nowMs - start) / 1000).coerceAtLeast(0) + } + + // Upload: real bytes fraction while pushing; done once the + // engine has the file (a transcribe job exists). + val uploadStage = PipelineStage( + "Upload", + when { + uploading -> "active" + t != null || hasTranscript -> "done" + else -> "pending" + }, + pct = uploadProgress?.let { (it * 100).toInt() }, + ) + // Transcribe: stage from the engine; real pct while running. + val tStage = PipelineStage( + "Transcription", + when { + t?.status == "failed" -> "failed" + tRunning -> "active" + t?.status == "succeeded" || hasTranscript -> "done" + else -> "pending" + }, + pct = t?.progress?.takeIf { tRunning }, + note = when { + tRunning && t?.stage == "loading-model" -> "loading model…" + tRunning && t?.progress == null -> "working…" + else -> null + }, + ) + // Summarize: engine reports streaming, never distance — so no + // pct, no ETA; the stage + elapsed are the honest signal. + val sStage = PipelineStage( + "Summary", + when { + s?.status == "failed" -> "failed" + s?.status == "running" -> "active" + s?.status == "queued" -> "active" + s?.status == "succeeded" || hasSummary -> "done" + else -> "pending" + }, + note = when { + s?.status == "queued" -> "queued on the summarizer" + s?.status == "running" && + (s.progress ?: 0) > 0 -> { + val tone = s.tone?.lowercase()?.replace(",", "") + ?.takeIf { it.isNotBlank() && it != "neutral" } + "writing${tone?.let { " in a $it voice" } ?: ""} · streaming reply" + } + s?.status == "running" -> "waiting for model" + else -> null + }, + ) + // Finalizing: engine says done but the app hasn't landed the + // fresh transcript/summary locally yet. + val finalizing = (t?.status == "succeeded" && !hasTranscript) || + (s?.status == "succeeded" && !hasSummary) + val fStage = PipelineStage( + "Finalizing", + when { + finalizing -> "active" + hasTranscript && hasSummary -> "done" + else -> "pending" + }, + ) + + val headline = when { + uploading -> "Uploading recording…" + tRunning -> "Transcribing recording…" + s?.status == "queued" -> "Queued: summary pending…" + s?.status == "running" -> "Summarizing recording…" + else -> "Finalizing…" + } + val activeJob = when { + tRunning -> t + sLive -> s + else -> null + } + val elapsed = elapsedOf(activeJob) + val eta = if (tRunning) etaFrom(elapsed ?: 0, t?.progress) else null + return PipelineUi(headline, + listOf(uploadStage, tStage, sStage, fStage), elapsed, eta) + } + const val KEY_URL = "server.url" const val KEY_FOLDER = "library.folder" const val KEY_PASSWORD = "local.password" @@ -1892,4 +2019,28 @@ data class SearchHit( ) /** What the pipeline is doing for a library file right now. */ -data class LiveProgress(val label: String, val fraction: Float?) \ No newline at end of file +data class LiveProgress(val label: String, val fraction: Float?) + +/** One row of the processing pipeline for the detail screen. + * States only change when real work moves: nothing here is animated + * by a timer pretending to know more than the engine. */ +data class PipelineStage( + val label: String, + /** "done" ✓ · "active" → · "pending" ○ · "failed" ✗ */ + val state: String, + /** Real 0-100 percentage when the backend reports one that means + * distance (upload bytes, transcribe audio-time); null when only + * the stage is known. */ + val pct: Int? = null, + /** Extra clause for the active stage ("loading model…"). */ + val note: String? = null, +) + +data class PipelineUi( + val headline: String, + val stages: List, + val elapsedSec: Long?, + /** Honest ETA: only when derived from a real percentage that measures + * distance (upload/transcribe). Never for summarize. */ + val etaSec: Long?, +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 564c2ea..e35d0ed 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect @@ -827,78 +828,20 @@ fun DetailScreen(state: DesktopState) { } Spacer(Modifier.height(4.dp)) } - detail.uploadProgress?.let { - LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth()) - Text("Uploading… ${(it * 100).toInt()}%") - } - detail.busy?.takeIf { detail.uploadProgress == null }?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text(it) - } - } - val tJob = detail.jobs.firstOrNull { it.jobType == "transcribe" } - tJob?.let { - val running = it.status == "running" - val label = when { - it.status == "succeeded" -> "Transcribed" - it.status == "failed" -> "Failed" - it.stage == "loading-model" -> "Loading model…" - running && it.progress != null -> "Transcribing… ${it.progress}%" - it.stage == "transcribing" -> "Transcribing…" - running -> "Working… (attempt ${it.attempt}/${it.maxAttempts})" - else -> "Queued" - } - Text(label, style = MaterialTheme.typography.bodyMedium, - color = if (it.status == "failed") MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary) - if (running) { - Spacer(Modifier.height(4.dp)) - if (it.progress != null) { - LinearProgressIndicator( - progress = { it.progress / 100f }, - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } - } - } + // Processing pipeline: real stages only. Each row flips when the + // engine actually moves; percentages appear only where the backend + // measures distance (upload bytes, transcribe audio-time). No + // decorative motion pretending to be progress. + val pipeline = DesktopState.pipelineStatus( + uploadProgress = detail.uploadProgress, + remoteKnown = state.remoteIdFor(detail.file) != null, + hasTranscript = detail.transcript != null, + hasSummary = detail.summary != null, + jobs = detail.jobs, + ) val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } - sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let { - // Honest milestone progress: the engine counts completed - // contract units (JSON keys/items finished), so the number - // means real work done — a determinate bar is truthful now. - val toneWord = sJob.tone?.lowercase()?.replace(",", "") - ?.takeIf { it.isNotBlank() && it != "neutral" } - val label = when { - it.status == "running" && it.progress != null && it.progress > 0 -> - "Summarizing" + (toneWord?.let { t -> " $t" } ?: "") + - "… ${it.progress}%" + - DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis()) - it.status == "running" -> "Summarizing… 0% waiting for model" + - DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis()) - else -> "Summary queued" + - DesktopState.waitSuffix(it.createdAt, System.currentTimeMillis()) - } - Text(label, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary) - if (it.status == "running") { - Spacer(Modifier.height(4.dp)) - if (it.progress != null && it.progress > 0) { - LinearProgressIndicator( - progress = { it.progress / 100f }, - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } - } + if (pipeline != null) { + PipelinePanel(pipeline) } // Rescue-swap note: the engine succeeded but had to switch // summarizers mid-job. Tell the user in plain words, not the raw @@ -931,7 +874,10 @@ fun DetailScreen(state: DesktopState) { } detail.error?.let { HelperText(it, error = true) - if (tJob?.status == "failed") { + val tFailed = detail.jobs.firstOrNull { j -> + j.jobType == "transcribe" && j.status == "failed" + } + if (tFailed != null) { ShonarButton({ state.retry() }) { Text("Retry transcription") } } } @@ -1088,43 +1034,21 @@ fun DetailScreen(state: DesktopState) { Text("Export…") } } - // Summarize progress right where the buttons are: a running - // summarize must be visible next to the chips that started it. - val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } + // Live status right where the chips are — one honest line + // mirroring the pipeline panel's Summary stage (the panel may + // be scrolled out of view when the chips get clicked). No + // bar animation: the engine reports stage, not distance. val sRunning = sJob?.status in setOf("queued", "running") - if (sRunning) { - val sStatus = sJob?.status - val sProg = sJob?.progress - val sAttempt = sJob?.attempt ?: 0 - // Determinate bar once real units complete: the engine - // counts finished JSON contract units, so the percent - // means work done, not vibes. Indeterminate only while - // queued or pre-first-token. - if (sProg != null && sProg > 0) { - LinearProgressIndicator(progress = { sProg / 100f }, - modifier = Modifier.fillMaxWidth()) - } else { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } + if (sRunning && pipeline != null) { + val sStage = pipeline.stages.firstOrNull { it.label == "Summary" } Text(buildString { - append(when { - sStatus == "queued" -> "Queued…" + - DesktopState.waitSuffix(sJob?.createdAt, - System.currentTimeMillis()) - sProg != null && sProg > 0 -> - "Summarizing" + (sJob?.tone?.lowercase() - ?.replace(",", "") - ?.takeIf { it.isNotBlank() && it != "neutral" } - ?.let { " $it" } ?: "") + - "… $sProg%" + - DesktopState.waitSuffix(sJob?.startedAt, - System.currentTimeMillis()) - else -> "Summarizing… 0% waiting for model" + - DesktopState.waitSuffix(sJob?.startedAt, - System.currentTimeMillis()) - }) - if (sAttempt > 0) - append(" (attempt $sAttempt/${sJob?.maxAttempts})") + append("→ ").append(sStage?.note + ?: if (sJob?.status == "queued") "queued on the summarizer" + else "writing") + pipeline.elapsedSec?.let { append(" · ${DesktopState.fmtMMSS(it)} elapsed") } + (sJob?.attempt ?: 0).let { + if (it > 0) append(" (attempt $it/${sJob?.maxAttempts})") + } }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) } @@ -1410,3 +1334,78 @@ fun SettingsScreen(state: DesktopState) { } } } + +/** Processing pipeline panel — stage truth from `DesktopState.pipelineStatus`. + * + * Each row carries exactly one state marker (✓ done, → current, ✗ failed, + * ○ not started) and only updates when the engine actually moves that + * stage. A percentage bar appears only when the backend reports real + * distance (upload bytes, transcribe audio-time); stage-only work shows + * a steady marker, never a moving fake bar. ETA appears only when the + * engine's percentage supports one. */ +@Composable +fun PipelinePanel(p: PipelineUi) { + SonarCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(p.headline, style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary) + Text( + buildString { + p.elapsedSec?.let { append("${DesktopState.fmtMMSS(it)} elapsed") } + p.etaSec?.let { + if (isNotEmpty()) append(" · ") + append("~${DesktopState.fmtMMSS(it)} remaining") + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(Shonar.Space2)) + p.stages.forEach { s -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Shonar.Space2), + ) { + Text( + when (s.state) { + "done" -> "✓" + "active" -> "→" + "failed" -> "✗" + else -> "○" + }, + style = MaterialTheme.typography.bodySmall, + color = when (s.state) { + "failed" -> MaterialTheme.colorScheme.error + "pending" -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.primary + }, + ) + Text( + s.label + (s.pct?.let { " $it%" } ?: ""), + style = MaterialTheme.typography.bodySmall, + color = if (s.state == "pending") + MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.onSurface, + ) + s.note?.let { + Text("· $it", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + s.pct?.takeIf { s.state == "active" }?.let { + LinearProgressIndicator( + progress = { it / 100f }, + modifier = Modifier + .padding(start = Shonar.Space4) + .fillMaxWidth() + .height(3.dp), + ) + } + } + } +} diff --git a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt index c78754f..c054e54 100644 --- a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt @@ -107,4 +107,86 @@ class JobProgressTest { // visible time too, so it must count from created_at. assertEquals(" · 2:04", DesktopState.waitSuffix(iso, t0)) } + + // ---- pipelineStatus: honest stage rows, no fake distance ---- + + private fun pjob(type: String, status: String, stage: String? = null, + prog: Int? = null, started: String? = null) = + JobInfo(jobType = type, status = status, attempt = 0, maxAttempts = 3, + stage = stage, progress = prog, startedAt = started) + + @Test fun `pipeline is null when idle`() { + assertNull(DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = true, hasSummary = true, jobs = emptyList())) + } + + @Test fun `summarize running shows stage not distance`() { + // The engine's summarize percent means streaming, not distance — + // so the pipeline must NOT surface a pct or ETA for it. + val now = System.currentTimeMillis() + val started = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(now - 42_000), java.time.ZoneOffset.UTC) + .withNano(0).toString() + val p = DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = true, hasSummary = false, + jobs = listOf(pjob("transcribe", "succeeded"), + pjob("summarize", "running", prog = 99, started = started)), + nowMs = now)!! + assertEquals("Summarizing recording…", p.headline) + assertNull(p.etaSec) + assertEquals(42L, p.elapsedSec) + val s = p.stages.first { it.label == "Summary" } + assertEquals("active", s.state) + assertNull(s.pct) + assertEquals(0, p.stages.count { it.state == "failed" }) + assertEquals("done", p.stages.first { it.label == "Transcription" }.state) + } + + @Test fun `transcribe running carries real pct and eta`() { + val now = System.currentTimeMillis() + val started = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(now - 60_000), java.time.ZoneOffset.UTC) + .withNano(0).toString() + val p = DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = false, hasSummary = false, + jobs = listOf(pjob("transcribe", "running", stage = "transcribing", + prog = 40, started = started)), + nowMs = now)!! + assertEquals("Transcribing recording…", p.headline) + val t = p.stages.first { it.label == "Transcription" } + assertEquals("active", t.state) + assertEquals(40, t.pct) + // 60s elapsed at 40% -> 90s remaining, both real-derived. + assertEquals(60L, p.elapsedSec) + assertEquals(90L, p.etaSec) + } + + @Test fun `upload fraction is a real pct on the Upload row`() { + val p = DesktopState.pipelineStatus( + uploadProgress = 0.66f, remoteKnown = false, + hasTranscript = false, hasSummary = false, jobs = emptyList())!! + assertEquals("Uploading recording…", p.headline) + val u = p.stages.first { it.label == "Upload" } + assertEquals("active", u.state) + assertEquals(66, u.pct) + } + + @Test fun `engine done but app not landed shows Finalizing active`() { + val p = DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = true, hasSummary = false, + jobs = listOf(pjob("transcribe", "succeeded"), + pjob("summarize", "succeeded")))!! + assertEquals("Finalizing…", p.headline) + assertEquals("active", p.stages.first { it.label == "Finalizing" }.state) + } + + @Test fun `eta withheld in the noisy edges of a run`() { + assertNull(DesktopState.etaFrom(10, 5)) // too early to trust + assertNull(DesktopState.etaFrom(300, 99)) // no promising the last token + assertEquals(25L, DesktopState.etaFrom(100, 80)) + } } From 70483b4cf805bb4a1477322710cc9ee1c36fb7cb Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 19:58:42 -0500 Subject: [PATCH 04/10] Voice chips follow the saved report's voice in saved-report view Opening a recording whose live summary row isn't loaded fell through to sumTone (null = Neutral), so the pill showed Neutral while the report text on screen read "*Voice: funny*". chipTone now reads the report's own *Voice: X* metadata line as the fallback, so the chips can never contradict the artifact being displayed. Running job and live summary rows still take precedence. 3 new tests (reportVoice). --- .../kotlin/com/shonar/desktop/DesktopState.kt | 15 +++++++++++++++ .../main/kotlin/com/shonar/desktop/Screens.kt | 5 +++++ .../com/shonar/desktop/JobProgressTest.kt | 18 ++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index d966881..f236365 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -1957,6 +1957,21 @@ class DesktopState(private val appDir: File = defaultAppDir()) { listOf(uploadStage, tStage, sStage, fStage), elapsed, eta) } + /** Voice recorded inside a saved .transcript.md report — the + * `*Voice: X*` metadata line written by Reports.renderReport. + * Used when the live summary row isn't loaded (saved-report + * view) so the voice chips can't contradict the report on + * screen. "neutral" (or absent) maps to null. Pure — tested. */ + fun reportVoice(reportText: String?): String? { + reportText ?: return null + val line = reportText.lineSequence().firstOrNull { + it.trim().startsWith("*Voice:") + } ?: return null + val v = line.trim().removePrefix("*Voice:").removeSuffix("*") + .trim() + return v.takeIf { it.isNotBlank() && !it.equals("neutral", true) } + } + const val KEY_URL = "server.url" const val KEY_FOLDER = "library.folder" const val KEY_PASSWORD = "local.password" diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index e35d0ed..cc8cbbd 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -683,6 +683,11 @@ fun DetailScreen(state: DesktopState) { val chipTone: String? = when { sJobNow != null && sJobNow.status in setOf("queued", "running") -> sJobNow.tone sSummary != null -> sSummary.tone + // Live summary row unavailable (saved-report view): read the + // voice out of the report itself so the chips can't contradict + // the "*Voice: funny*" text on screen (user: report said funny, + // Neutral pill was highlighted). + detail.reportText != null -> DesktopState.reportVoice(detail.reportText) else -> sumTone } diff --git a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt index c054e54..a8f6bf4 100644 --- a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt @@ -189,4 +189,22 @@ class JobProgressTest { assertNull(DesktopState.etaFrom(300, 99)) // no promising the last token assertEquals(25L, DesktopState.etaFrom(100, 80)) } + + // ---- reportVoice: chips follow the saved report's own voice ---- + + @Test fun `reportVoice reads the metadata line`() { + val md = "# X\n## Summary\n*Voice: funny*\n\ntext" + assertEquals("funny", DesktopState.reportVoice(md)) + } + + @Test fun `reportVoice neutral and absent map to null`() { + assertNull(DesktopState.reportVoice("# X\n*Voice: neutral*\n")) + assertNull(DesktopState.reportVoice("# X\nno voice line\n")) + assertNull(DesktopState.reportVoice(null)) + } + + @Test fun `reportVoice keeps multi-word tones intact`() { + assertEquals("dry, witty", + DesktopState.reportVoice("*Voice: dry, witty*")) + } } From b5836d0fb637162357068ff3b63f5d24c0bd5113 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 21:11:58 -0500 Subject: [PATCH 05/10] Keep watching the job through transient fetch failures; don't freeze the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reprocessInPlace's poll loop swallowed fetchJobs failures as an empty list and broke on the resulting missing status, so ONE dropped poll during a 10-minute summarize ended the watch early: chips stayed disabled, the screen showed stale content, and only leaving to the library and back (fresh openDetail fetch) displayed the finished summary — the exact reported symptom. Now only a successful fetch reporting a terminal state ends the loop; transient failures keep polling for ~30s, and a genuinely-lost engine says so in plain words instead of silently stranding the screen. A missing job row on healthy fetches gets 3 grace cycles before giving up (engine rewrites rows mid-requeue). --- .../kotlin/com/shonar/desktop/DesktopState.kt | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index f236365..5fc14a6 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -1632,11 +1632,30 @@ class DesktopState(private val appDir: File = defaultAppDir()) { return@launch } } - // Poll the target job to a terminal state. + // Poll the target job to a terminal state. A job this long + // (10+ min = hundreds of polls) WILL hit a transient fetch + // failure; treating one as terminal (the old `orEmpty()` → + // st == null → break) froze the screen on stale content until + // the user left and returned — exactly the reported symptom. + // So: only a SUCCESSFUL fetch reporting a terminal state + // breaks; failures keep polling up to ~30s of contact loss. + var miss = 0 + var gone = 0 while (true) { delay(2000) - val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull() - .orEmpty() + val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) } + if (fetched.isFailure) { + if (++miss >= 15) { + d0.error = "Lost contact with the engine while " + + "processing — reopen this recording to check." + d0.busy = null + _detail.value = d0.copy() + return@launch + } + continue + } + miss = 0 + val jobs = fetched.getOrDefault(emptyList()) d0.jobs = jobs jobLabel(jobs)?.let { d0.busy = it } // Mirror live progress onto the library row too — otherwise @@ -1646,7 +1665,10 @@ class DesktopState(private val appDir: File = defaultAppDir()) { rescanStatuses() _detail.value = d0.copy() val st = jobs.firstOrNull { it.jobType == job }?.status - if (st == null || st in TERMINAL) break + if (st in TERMINAL) break + // Job row missing from a healthy fetch: give it a few + // cycles (the engine rewrites rows mid-requeue) then stop. + if (st == null && ++gone >= 3) break } setLive(d0.file.name, null) rescanStatuses() From 05b84a361a7e4cf0b77ad04ff33a4d7142b537db Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 21:18:30 -0500 Subject: [PATCH 06/10] Show the busy label the instant a click lands, before the first job row exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between pressing Transcribe and the first /jobs poll there was a blind window where real work (POST in flight, token refresh, initial upload) had no on-screen trace at all — the screenshot complaint 'shows nothing'. Any non-null busy label now renders as a pipeline-style card in the same slot the panel occupies, so feedback starts at click time. --- app/src/main/kotlin/com/shonar/desktop/Screens.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index cc8cbbd..7b3e4a7 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -847,6 +847,21 @@ fun DetailScreen(state: DesktopState) { val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } if (pipeline != null) { PipelinePanel(pipeline) + } else if (detail.busy != null) { + // Click-landed but no job row yet (POST/token/poll latency): + // busy is REAL work in flight — showing nothing here was the + // "hit transcribe, screen looks dead" blind window. + SonarCard(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Shonar.Space2), + ) { + Text("→", style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary) + Text(detail.busy!!, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary) + } + } } // Rescue-swap note: the engine succeeded but had to switch // summarizers mid-job. Tell the user in plain words, not the raw From b541cc231442cdb4eea38f16f675b034763125f7 Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 22:05:04 -0500 Subject: [PATCH 07/10] Never swallow detail-screen updates: revision field on DetailUi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE root cause behind "summary changed but the open screen kept the old voice until I left to the dashboard and back": the app mutates ONE DetailUi instance in place and republishes it with copy(). StateFlow drops any assignment equal() to its current value — and a copy of the very object it already holds is ALWAYS equal (same mutated fields). So every publish after the first was silently dropped. The screen only ever refreshed by accident, when some other collected flow (models, playback, library rows) happened to trigger a recomposition that re-read the mutated fields mid-run; the final summary publish, with nothing else changing at that moment, just vanished. Engine logs proved the app fetched the fresh sarcastic summary — the UI received it and threw it away. DetailUi gains a rev discriminator bumped on every publish (23 sites), so no update can ever conflate away. Two regression tests pin the conflation semantics. --- .../kotlin/com/shonar/desktop/DesktopState.kt | 68 +++++++++++-------- .../com/shonar/desktop/JobProgressTest.kt | 20 ++++++ 2 files changed, 59 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 5fc14a6..e0aa473 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -66,6 +66,16 @@ class DesktopState(private val appDir: File = defaultAppDir()) { * an already-transcribed file (no live transcript in memory). */ var reportText: String? = null, var error: String? = null, + /** Emission discriminator. The whole class mutates ONE DetailUi + * instance in place and republishes with copy(); StateFlow drops + * any assignment that `equals()` the current value — and a copy + * of the very object the flow already holds is ALWAYS equal + * (same mutated fields). That silently swallowed updates, the + * most visible one being the finished summary: the job landed, + * the report on disk rewrote, but the open screen stayed on the + * old voice until the user left to the library and back. Every + * publish bumps this so no update can ever conflate away. */ + val rev: Long = 0L, ) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -912,7 +922,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { _liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(old.name) } - if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target) + if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target, rev = System.nanoTime()) rescan() return null } @@ -1351,17 +1361,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // idle "Transcribe" button for a file already working. if (d.remoteId == null && file.name in inFlight) { d.busy = _liveProgress.value[file.name]?.label ?: "queued for transcription…" - if (_detail.value?.file == file) _detail.value = d.copy() + if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime()) while (file.name in inFlight && _detail.value?.file == file) { delay(500) d.busy = _liveProgress.value[file.name]?.label ?: "uploading…" - if (_detail.value?.file == file) _detail.value = d.copy() + if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime()) } if (_detail.value?.file != file) return@launch d.busy = null d.remoteId = loadMapping(file)?.recordingId d.reportText = runCatching { reportFile(file).readText() }.getOrNull() - if (_detail.value?.file == file) _detail.value = d.copy() + if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime()) } // Files that predate remote-id mapping have no sidecar: adopt // the server recording by exact title match so Re-summarize @@ -1386,7 +1396,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d.transcript = t ?: d.transcript d.summary = s saveReport(d) - if (_detail.value?.file == file) _detail.value = d.copy() + if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime()) } } // If the file is mid-pipeline (pump running, or reprocess started @@ -1412,7 +1422,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } d.jobs = jobs jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null } - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) val active = jobs.any { it.status == "running" || it.status == "queued" } sawActive = sawActive || active if (!active) break @@ -1436,7 +1446,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d.busy = null d.reportText = runCatching { reportFile(file).readText() }.getOrNull() ?: d.reportText - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) rescanStatuses() } } @@ -1469,7 +1479,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } fun setOverride(model: String?) { - _detail.value?.let { it.overrideModel = model; _detail.value = it.copy() } + _detail.value?.let { it.overrideModel = model; _detail.value = it.copy(rev = System.nanoTime()) } } fun transcribe() { @@ -1483,7 +1493,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (d.file.name in inFlight || isRunning(d.file.name)) { d.busy = _liveProgress.value[d.file.name]?.label ?: "Already queued…" d.error = null - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) return } // A file with a known server recording re-runs the pipeline IN @@ -1509,7 +1519,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d.busy = "Uploading…" d.uploadProgress = 0f d.error = null - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) val draft = RecordingDraft( id = UUID.randomUUID().toString(), title = d.file.nameWithoutExtension, @@ -1528,12 +1538,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // always change something on screen, immediately. setLive(d.file.name, LiveProgress("uploading…", p)) rescanStatuses() - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) } }.getOrElse { d.busy = null d.error = it.message ?: "Upload failed." - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) return@run } d.remoteId = ref.key @@ -1541,7 +1551,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { title = d.file.nameWithoutExtension)) d.busy = "Transcribing…" d.uploadProgress = null - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) // Poll jobs until the WHOLE pipeline is terminal. Breaking on // transcribe alone froze the screen: the last d.jobs snapshot // still showed summarize running at 0%, and nothing refreshed @@ -1557,7 +1567,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // doesn't make the job look dead. setLive(d.file.name, jobProgress(jobs)) rescanStatuses() - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status val s = jobs.firstOrNull { it.jobType == "summarize" }?.status if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break @@ -1568,20 +1578,20 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (failed != null) { d.busy = null d.error = failed.error ?: "Transcription failed." - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) return@run } d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } .getOrNull() d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull() d.busy = null - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) if (d.transcript != null) { saveReport(d) rescan() } else { d.error = "Transcription finished but no transcript was returned." - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) } } } @@ -1605,7 +1615,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { else "Summarizing… 0%" d0.uploadProgress = null d0.error = null - _detail.value = d0.copy() + _detail.value = d0.copy(rev = System.nanoTime()) runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure { if (job == "transcribe" && it is ProviderError.NotFound) { // Mapping points at a deleted recording: drop it so the @@ -1614,7 +1624,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d0.remoteId = null d0.busy = null d0.error = "That server recording no longer exists — press Transcribe to upload again." - _detail.value = d0.copy() + _detail.value = d0.copy(rev = System.nanoTime()) return@launch } else if (it is ProviderError.Transient && it.message?.contains("already running") == true) { @@ -1628,7 +1638,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } else { d0.busy = null d0.error = it.message ?: "Reprocess failed." - _detail.value = d0.copy() + _detail.value = d0.copy(rev = System.nanoTime()) return@launch } } @@ -1649,7 +1659,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d0.error = "Lost contact with the engine while " + "processing — reopen this recording to check." d0.busy = null - _detail.value = d0.copy() + _detail.value = d0.copy(rev = System.nanoTime()) return@launch } continue @@ -1663,7 +1673,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // detail screen (the pump path already does this via setLive). setLive(d0.file.name, jobProgress(jobs)) rescanStatuses() - _detail.value = d0.copy() + _detail.value = d0.copy(rev = System.nanoTime()) val st = jobs.firstOrNull { it.jobType == job }?.status if (st in TERMINAL) break // Job row missing from a healthy fetch: give it a few @@ -1676,7 +1686,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (failed != null) { d0.busy = null d0.error = failed.error ?: "That stage failed." - _detail.value = d0.copy() + _detail.value = d0.copy(rev = System.nanoTime()) return@launch } d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } @@ -1684,7 +1694,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } .getOrNull() d0.busy = null - _detail.value = d0.copy() + _detail.value = d0.copy(rev = System.nanoTime()) d0.summary?.let { announceSummaryReady(d0.file, it.version) } if (d0.transcript != null || d0.summary != null) { saveReport(d0) @@ -1710,7 +1720,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { // opened for a since-renamed/deleted file). Say what's wrong. d.error = "This file has no server recording to summarize — " + "press Transcribe to upload it first." - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) return } d.remoteId = remoteId @@ -1730,7 +1740,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (remoteId == null) { d.error = "This file has no server recording to save against — " + "press Transcribe to upload it first." - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) return } d.remoteId = remoteId @@ -1738,7 +1748,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { scope.launch { d.busy = "Saving transcript…" d.error = null - _detail.value = d.copy() + _detail.value = d.copy(rev = System.nanoTime()) runCatching { parseTranscript(provider.updateTranscript(remoteId, org.json.JSONObject().put("text", text).toString())) } .onSuccess { saved -> @@ -1747,13 +1757,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) { saveReport(d) } d.busy = null - if (_detail.value?.file == d.file) _detail.value = d.copy() + if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime()) rescanStatuses() } .onFailure { d.busy = null d.error = it.message ?: "Transcript save failed." - if (_detail.value?.file == d.file) _detail.value = d.copy() + if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime()) } } } diff --git a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt index a8f6bf4..2aa95fe 100644 --- a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt @@ -207,4 +207,24 @@ class JobProgressTest { assertEquals("dry, witty", DesktopState.reportVoice("*Voice: dry, witty*")) } + + // ---- DetailUi revision: mutated-in-place publishes must not conflate ---- + + @Test fun `mutated copy without rev equals stale value (the old bug)`() { + // Documents WHY DetailUi.rev exists: the app mutates one instance + // and republishes copy(); StateFlow would drop that assignment as + // "no change" — the finished summary never reached the open screen. + val d = DesktopState.DetailUi(file = java.io.File("/tmp/x.m4a")) + val published = d.copy() + d.summary = null // mutate the SAME instance + assertEquals(published, d.copy()) + } + + @Test fun `rev bump makes every republish distinct`() { + val d = DesktopState.DetailUi(file = java.io.File("/tmp/x.m4a")) + val before = d.copy(rev = 1L) + d.summary = null + val after = d.copy(rev = 2L) + org.junit.Assert.assertNotEquals(before, after) + } } From 15381b2c41218aa674838993d0c811f52dd2c48b Mon Sep 17 00:00:00 2001 From: avi Date: Fri, 18 Sep 2026 23:17:04 -0500 Subject: [PATCH 08/10] Commit upload session row before responding; client's follow-up GET raced the post-response commit into a 404 --- backend/shonar/api/v1/recordings.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py index 65cfaa0..73a745a 100644 --- a/backend/shonar/api/v1/recordings.py +++ b/backend/shonar/api/v1/recordings.py @@ -86,7 +86,13 @@ async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: S session, user.id, body.declared_mime_type, body.declared_size_bytes, body.title, body.client_recording_id, body.transcription_model, ) + # Commit BEFORE responding: FastAPI's get_session commits after the + # response is sent, and the desktop client GETs /uploads/{id} the + # instant it sees the 201 — an uncommitted row raced that follow-up + # into a 404 "Upload status failed" (seen live Sep 18). + await session.commit() except up.UploadError as e: + await session.rollback() raise HTTPException(e.status_code, e.message) from None return us From 514d75fe921a294df80148b90408560e8c70873b Mon Sep 17 00:00:00 2001 From: avi Date: Sat, 19 Sep 2026 15:24:50 -0500 Subject: [PATCH 09/10] Raw provider crash fails the job instead of escaping into the sweep requeue loop A non-AIError from transcribe/summarize (av.InvalidDataError on corrupt audio, seen live with 'My recording 63') escaped the consumer, left the row 'running', and got requeued at every engine restart forever. Both runners now catch it, fail the row with the exception type in the message, and keep a usable transcript (summarize completes with a 'Summary failed' note). Regression tests pin both paths (proven red without the fix). --- backend/shonar/services/processing.py | 34 +++++++++++++++++++++++ backend/tests/test_ai_pipeline.py | 39 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/backend/shonar/services/processing.py b/backend/shonar/services/processing.py index 35da1af..6f77c85 100644 --- a/backend/shonar/services/processing.py +++ b/backend/shonar/services/processing.py @@ -435,6 +435,21 @@ async def run_transcribe(ctx: dict, recording_id: str) -> None: await _fail(session, rec, job, str(e), ctx, e) await session.commit() return + except asyncio.CancelledError: + raise + except Exception as e: # noqa: BLE001 — a raw provider crash (e.g. av.InvalidDataError + # on corrupt audio) must FAIL the job, not escape: an escaping + # exception leaves the row 'running' and the startup sweep + # requeues it forever (crash-loop seen live Sep 19). + logger.exception("transcribe provider raised for recording %s", rec.id) + job.status = JobStatus.failed + job.error = f"Transcription crashed: {type(e).__name__}: {e}"[:500] + job.stage = None + job.finished_at = utcnow() + rec.processing_status = ProcessingStatus.failed + rec.processing_error = job.error + await session.commit() + return await store_transcript( session, rec, result.text, result.segments, result.language, provider.name, getattr(result, "model", ""), @@ -549,6 +564,25 @@ async def run_summarize(ctx: dict, recording_id: str) -> None: await session.commit() return result, result_provider, fallback_note = rescue + except asyncio.CancelledError: + raise + except Exception as e: # noqa: BLE001 — same reason as transcribe: a raw + # provider crash must fail the row, not escape into the sweep loop. + logger.exception("summarize provider raised for recording %s", rec.id) + job.status = JobStatus.failed + job.error = f"Summarize crashed: {type(e).__name__}: {e}"[:500] + job.stage = None + job.finished_at = utcnow() + # A usable transcript survives: complete with the failure note + # (mirrors _fail's transcript-preserving rule). + if await latest_transcript_text(session, rec.id): + rec.processing_status = ProcessingStatus.completed + rec.processing_error = f"Summary failed: {job.error}" + else: + rec.processing_status = ProcessingStatus.failed + rec.processing_error = job.error + await session.commit() + return await store_summary(session, rec, result.to_dict(), result_provider, result.model, tone=job.tone) job.status = JobStatus.succeeded diff --git a/backend/tests/test_ai_pipeline.py b/backend/tests/test_ai_pipeline.py index 166535c..7b7a18e 100644 --- a/backend/tests/test_ai_pipeline.py +++ b/backend/tests/test_ai_pipeline.py @@ -251,6 +251,45 @@ async def test_config_error_fails_fast(client, monkeypatch): assert stt.calls == 1 +async def test_raw_provider_crash_fails_job_no_escape(client, monkeypatch): + """A non-AIError from the provider (e.g. av.InvalidDataError on corrupt + audio) must FAIL the row, not escape: an escaping exception leaves the + job 'running' and the startup sweep requeues it forever.""" + stt = FakeTranscriber(fail=ValueError("Invalid data found when processing input")) + use_fakes(monkeypatch, stt, FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-crash-1") + await processing.run_transcribe({"job_try": 1}, rec["id"]) # must NOT raise + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.transcribe].status == JobStatus.failed + assert "ValueError" in (jobs[JobType.transcribe].error or "") + h = {"Authorization": f"Bearer {token}"} + r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) + assert r.json()["processing_status"] == "failed" + + +async def test_raw_summarize_crash_keeps_transcript(client, monkeypatch): + use_fakes(monkeypatch, FakeTranscriber("fine text"), FakeLlm()) + token = await user_tokens(client) + rec = await upload_recording(client, token, client_id="m7-crash-2") + await processing.run_transcribe({}, rec["id"]) + + class ExplodingLlm(FakeLlm): + async def summarize(self, transcript, **kw): + raise RuntimeError("llm segfaulted") + + use_fakes(monkeypatch, FakeTranscriber("fine text"), ExplodingLlm()) + await processing.run_summarize({"job_try": 1}, rec["id"]) # must NOT raise + jobs = await jobs_for(rec["id"]) + assert jobs[JobType.summarize].status == JobStatus.failed + h = {"Authorization": f"Bearer {token}"} + r = await client.get(f"/api/v1/recordings/{rec['id']}", headers=h) + body = r.json() + # Transcript survived: recording completes with a summary-failure note. + assert body["processing_status"] == "completed" + assert "Summary failed" in body["processing_error"] + + # --- versioning ----------------------------------------------------------------- From c32fc2079c44dad6086551c8b774e00e691a9871 Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 20 Sep 2026 20:49:21 -0500 Subject: [PATCH 10/10] PipelinePanel: wrap card content in a Column so stages stop painting over the headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCard's Surface lays un-wrapped siblings in a Box, so the whole processing panel (teal headline, gray elapsed/note text, stage rows, progress bar) drew stacked on one line — the 'text overlapping while summarizing' garble, visible only mid-job. Every other card call site wraps its content; this was the one that didn't. Headline also gets weight(1f, fill=false) + maxLines/Ellipsis so it can't collide with the elapsed/ETA text at narrow widths. --- app/src/main/kotlin/com/shonar/desktop/Screens.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 7b3e4a7..a990ad2 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -1366,13 +1366,22 @@ fun SettingsScreen(state: DesktopState) { @Composable fun PipelinePanel(p: PipelineUi) { SonarCard(modifier = Modifier.fillMaxWidth()) { + // SonarCard's Surface places un-wrapped siblings in a Box — without + // this Column every row stacked on top of the headline (the + // "text overlapping while summarizing" garble). + Column( + modifier = Modifier.fillMaxWidth().padding(Shonar.CardPadding), + verticalArrangement = Arrangement.spacedBy(Shonar.Space1), + ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text(p.headline, style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.primary) + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f, fill = false), + maxLines = 1, overflow = TextOverflow.Ellipsis) Text( buildString { p.elapsedSec?.let { append("${DesktopState.fmtMMSS(it)} elapsed") } @@ -1427,5 +1436,6 @@ fun PipelinePanel(p: PipelineUi) { ) } } + } } }