diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index a3af382..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,15 +1638,34 @@ 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 } } - // 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(rev = System.nanoTime()) + 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 @@ -1644,9 +1673,12 @@ 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 == 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() @@ -1654,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)) } @@ -1662,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) @@ -1688,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 @@ -1708,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 @@ -1716,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 -> @@ -1725,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()) } } } @@ -1744,17 +1776,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 { @@ -1786,6 +1841,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 @@ -1807,6 +1876,134 @@ 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) + } + + /** 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" @@ -1857,9 +2054,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") } } @@ -1872,4 +2066,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/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..a990ad2 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,16 @@ 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.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect @@ -82,9 +76,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 +135,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 +149,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 +161,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 +546,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 +657,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() @@ -611,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 } @@ -619,29 +696,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 +733,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 +755,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 +787,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 +816,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) } @@ -753,76 +833,33 @@ 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) + } 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) } } } @@ -852,18 +889,21 @@ 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) - if (tJob?.status == "failed") { - Button({ state.retry() }) { Text("Retry transcription") } + HelperText(it, error = true) + val tFailed = detail.jobs.firstOrNull { j -> + j.jobType == "transcribe" && j.status == "failed" + } + if (tFailed != null) { + 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 +930,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 +960,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,68 +1023,52 @@ 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") } - } - // 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" } - 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()) + // 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…") } + } + // 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 && 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) } @@ -1072,7 +1094,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 +1116,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 +1162,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 +1214,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 +1249,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 +1293,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 +1309,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") } } @@ -1322,3 +1354,88 @@ 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()) { + // 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, + modifier = Modifier.weight(1f, fill = false), + maxLines = 1, overflow = TextOverflow.Ellipsis) + 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..2aa95fe 100644 --- a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt @@ -107,4 +107,124 @@ 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)) + } + + // ---- 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*")) + } + + // ---- 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) + } } 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() } 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 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 -----------------------------------------------------------------