diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index e0aa473..a3af382 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -66,16 +66,6 @@ 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) @@ -922,7 +912,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, rev = System.nanoTime()) + if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target) rescan() return null } @@ -1361,17 +1351,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(rev = System.nanoTime()) + if (_detail.value?.file == file) _detail.value = d.copy() 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(rev = System.nanoTime()) + if (_detail.value?.file == file) _detail.value = d.copy() } 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(rev = System.nanoTime()) + if (_detail.value?.file == file) _detail.value = d.copy() } // Files that predate remote-id mapping have no sidecar: adopt // the server recording by exact title match so Re-summarize @@ -1396,7 +1386,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(rev = System.nanoTime()) + if (_detail.value?.file == file) _detail.value = d.copy() } } // If the file is mid-pipeline (pump running, or reprocess started @@ -1422,7 +1412,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(rev = System.nanoTime()) + _detail.value = d.copy() val active = jobs.any { it.status == "running" || it.status == "queued" } sawActive = sawActive || active if (!active) break @@ -1446,7 +1436,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d.busy = null d.reportText = runCatching { reportFile(file).readText() }.getOrNull() ?: d.reportText - _detail.value = d.copy(rev = System.nanoTime()) + _detail.value = d.copy() rescanStatuses() } } @@ -1479,7 +1469,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } fun setOverride(model: String?) { - _detail.value?.let { it.overrideModel = model; _detail.value = it.copy(rev = System.nanoTime()) } + _detail.value?.let { it.overrideModel = model; _detail.value = it.copy() } } fun transcribe() { @@ -1493,7 +1483,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(rev = System.nanoTime()) + _detail.value = d.copy() return } // A file with a known server recording re-runs the pipeline IN @@ -1519,7 +1509,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d.busy = "Uploading…" d.uploadProgress = 0f d.error = null - _detail.value = d.copy(rev = System.nanoTime()) + _detail.value = d.copy() val draft = RecordingDraft( id = UUID.randomUUID().toString(), title = d.file.nameWithoutExtension, @@ -1538,12 +1528,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(rev = System.nanoTime()) + _detail.value = d.copy() } }.getOrElse { d.busy = null d.error = it.message ?: "Upload failed." - _detail.value = d.copy(rev = System.nanoTime()) + _detail.value = d.copy() return@run } d.remoteId = ref.key @@ -1551,7 +1541,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { title = d.file.nameWithoutExtension)) d.busy = "Transcribing…" d.uploadProgress = null - _detail.value = d.copy(rev = System.nanoTime()) + _detail.value = d.copy() // 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 @@ -1567,7 +1557,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(rev = System.nanoTime()) + _detail.value = d.copy() 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 @@ -1578,20 +1568,20 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (failed != null) { d.busy = null d.error = failed.error ?: "Transcription failed." - _detail.value = d.copy(rev = System.nanoTime()) + _detail.value = d.copy() 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(rev = System.nanoTime()) + _detail.value = d.copy() if (d.transcript != null) { saveReport(d) rescan() } else { d.error = "Transcription finished but no transcript was returned." - _detail.value = d.copy(rev = System.nanoTime()) + _detail.value = d.copy() } } } @@ -1615,7 +1605,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { else "Summarizing… 0%" d0.uploadProgress = null d0.error = null - _detail.value = d0.copy(rev = System.nanoTime()) + _detail.value = d0.copy() 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 @@ -1624,7 +1614,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(rev = System.nanoTime()) + _detail.value = d0.copy() return@launch } else if (it is ProviderError.Transient && it.message?.contains("already running") == true) { @@ -1638,34 +1628,15 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } else { d0.busy = null d0.error = it.message ?: "Reprocess failed." - _detail.value = d0.copy(rev = System.nanoTime()) + _detail.value = d0.copy() return@launch } } - // 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 + // Poll the target job to a terminal state. while (true) { delay(2000) - 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()) + val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull() + .orEmpty() d0.jobs = jobs jobLabel(jobs)?.let { d0.busy = it } // Mirror live progress onto the library row too — otherwise @@ -1673,12 +1644,9 @@ 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(rev = System.nanoTime()) + _detail.value = d0.copy() val st = jobs.firstOrNull { it.jobType == job }?.status - 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 + if (st == null || st in TERMINAL) break } setLive(d0.file.name, null) rescanStatuses() @@ -1686,7 +1654,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(rev = System.nanoTime()) + _detail.value = d0.copy() return@launch } d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } @@ -1694,7 +1662,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } .getOrNull() d0.busy = null - _detail.value = d0.copy(rev = System.nanoTime()) + _detail.value = d0.copy() d0.summary?.let { announceSummaryReady(d0.file, it.version) } if (d0.transcript != null || d0.summary != null) { saveReport(d0) @@ -1720,7 +1688,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(rev = System.nanoTime()) + _detail.value = d.copy() return } d.remoteId = remoteId @@ -1740,7 +1708,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(rev = System.nanoTime()) + _detail.value = d.copy() return } d.remoteId = remoteId @@ -1748,7 +1716,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { scope.launch { d.busy = "Saving transcript…" d.error = null - _detail.value = d.copy(rev = System.nanoTime()) + _detail.value = d.copy() runCatching { parseTranscript(provider.updateTranscript(remoteId, org.json.JSONObject().put("text", text).toString())) } .onSuccess { saved -> @@ -1757,13 +1725,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) { saveReport(d) } d.busy = null - if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime()) + if (_detail.value?.file == d.file) _detail.value = d.copy() rescanStatuses() } .onFailure { d.busy = null d.error = it.message ?: "Transcript save failed." - if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime()) + if (_detail.value?.file == d.file) _detail.value = d.copy() } } } @@ -1776,40 +1744,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) { reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary)) } - /** 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." - } + /** 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}" }, + ) } companion object { @@ -1841,20 +1786,6 @@ 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 @@ -1876,134 +1807,6 @@ 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" @@ -2054,6 +1857,9 @@ 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") } } @@ -2066,28 +1872,4 @@ data class SearchHit( ) /** What the pipeline is doing for a library file right now. */ -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 +data class LiveProgress(val label: String, val fraction: Float?) \ 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 7860e5a..3f64b12 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -9,8 +9,11 @@ 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 @@ -152,39 +155,41 @@ fun main() = application { val summaryToast by state.summaryToast.collectAsState() Column(Modifier.fillMaxSize()) { summaryToast?.let { t -> - SonarCard( + Card( Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer), ) { Row(Modifier.fillMaxWidth() - .padding(horizontal = Shonar.CardPadding), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { Text(t.label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) - ShonarGhostButton({ state.viewSummaryReady(t) }) { + TextButton({ state.viewSummaryReady(t) }) { Text("View") } - ShonarGhostButton({ state.dismissSummaryToast() }) { + TextButton({ state.dismissSummaryToast() }) { Text("✕") } } } - Spacer(Modifier.height(Shonar.Space2)) + Spacer(Modifier.height(8.dp)) } undoToast?.let { t -> - SonarCard(Modifier.fillMaxWidth()) { + Card(Modifier.fillMaxWidth()) { Row(Modifier.fillMaxWidth() - .padding(horizontal = Shonar.CardPadding), + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { Text(t.label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) - ShonarGhostButton({ state.undoLastTrash() }) { + TextButton({ state.undoLastTrash() }) { Text("Undo") } } } - Spacer(Modifier.height(Shonar.Space2)) + Spacer(Modifier.height(8.dp)) } 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 a990ad2..a916033 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -1,5 +1,6 @@ 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 @@ -24,16 +25,21 @@ 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.runtime.Composable +import androidx.compose.material3.TextButton import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect @@ -76,9 +82,9 @@ fun EngineScreen(state: DesktopState) { } else -> { error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { - ShonarButton({ state.startEngine() }) { Text("Start engine") } - ShonarOutlinedButton({ state.ensureReady() }) { Text("Retry") } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button({ state.startEngine() }) { Text("Start engine") } + OutlinedButton({ state.ensureReady() }) { Text("Retry") } } } } @@ -135,11 +141,11 @@ fun FolderPickerDialog( verticalArrangement = Arrangement.spacedBy(6.dp)) { // Breadcrumb row: home + each step; click to jump. Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - ShonarGhostButton({ current = home }) { Text("Home") } + TextButton({ current = home }) { Text("Home") } crumbs.dropWhile { it.absolutePath == home.absolutePath } .forEach { d -> Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant) - ShonarGhostButton({ current = d }) { + TextButton({ current = d }) { Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1) } } @@ -149,7 +155,7 @@ fun FolderPickerDialog( color = MaterialTheme.colorScheme.onSurfaceVariant) HorizontalDivider() if (current.parentFile != null) { - ShonarGhostButton( + TextButton( { current.parentFile?.let { current = it } }, modifier = Modifier.fillMaxWidth(), ) { Text("⋱ .. (go up)") } @@ -161,31 +167,31 @@ fun FolderPickerDialog( } LazyColumn(Modifier.weight(1f)) { items(subs, key = { it.absolutePath }) { d -> - SonarCard(Modifier.fillMaxWidth(), - onClick = { current = d }) { - Row(Modifier.fillMaxWidth().padding(horizontal = Shonar.CardPadding, - vertical = Shonar.Space2), + Card(Modifier.fillMaxWidth().clickable { current = d }) { + Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, + vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text("▸", color = MaterialTheme.colorScheme.primary) - Spacer(Modifier.width(Shonar.Space2)) + Spacer(Modifier.width(8.dp)) Text(d.name, style = MaterialTheme.typography.bodyMedium) } } - Spacer(Modifier.height(Shonar.Space1)) + Spacer(Modifier.height(4.dp)) } } error?.let { - HelperText(it, error = true) + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) } } }, confirmButton = { - ShonarGhostButton({ + TextButton({ if (current.isDirectory) onResult(current) else error = "That folder no longer exists." }) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") } }, - dismissButton = { ShonarGhostButton({ onResult(null) }) { Text("Cancel") } }, + dismissButton = { TextButton({ onResult(null) }) { Text("Cancel") } }, ) } @@ -546,83 +552,6 @@ 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 @@ -657,7 +586,6 @@ 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() @@ -683,11 +611,6 @@ 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 } @@ -696,28 +619,29 @@ fun DetailScreen(state: DesktopState) { // the complaint. Everything below scrolls as one flow. Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } + TextButton({ 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. - ShonarGhostButton( + TextButton( onClick = { state.neighborOf(detail.file, forward = false) ?.let(state::openDetail) }, enabled = state.neighborOf(detail.file, forward = false) != null, ) { Text("← Prev") } - ShonarGhostButton( + TextButton( onClick = { state.neighborOf(detail.file, forward = true) ?.let(state::openDetail) }, enabled = state.neighborOf(detail.file, forward = true) != null, ) { Text("Next →") } - ShonarDangerButton( + TextButton( { confirmDelete = true }, enabled = detail.busy == null, - ) { - Text("Delete") - } + content = { + Text("Delete", color = MaterialTheme.colorScheme.error) + }, + ) } Column(Modifier.fillMaxWidth().weight(1f) .verticalScroll(rememberScrollState()), @@ -733,10 +657,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). - ShonarButton({ state.stopPlayback() }) { + Button({ state.stopPlayback() }) { Icon(Icons.Filled.Stop, contentDescription = null, - modifier = Modifier.size(Shonar.IconSm)) - Spacer(Modifier.width(Shonar.Space2)) + modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) Text("Stop") } if (transport && ps.durationSec > 0) { @@ -755,27 +679,23 @@ fun DetailScreen(state: DesktopState) { Spacer(Modifier.weight(1f)) } } else { - ShonarOutlinedButton({ state.playFrom(detail.file) }) { + OutlinedButton({ state.playFrom(detail.file) }) { Icon(Icons.Filled.PlayArrow, contentDescription = null, - modifier = Modifier.size(Shonar.IconSm)) - Spacer(Modifier.width(Shonar.Space2)) + modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) Text("Play") } } playError?.let { - HelperText(it, error = true) + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) } } // Search within this recording: hits jump the audio to that moment. if (transcriptLines.isNotEmpty()) { - 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)) - }) + OutlinedTextField(recQuery, { recQuery = it }, + label = { Text("Search this recording") }, + singleLine = true, modifier = Modifier.fillMaxWidth()) if (recQuery.isNotBlank()) { if (recHits.isEmpty()) { Text("No matches in this recording.", @@ -787,20 +707,20 @@ fun DetailScreen(state: DesktopState) { color = MaterialTheme.colorScheme.onSurfaceVariant) LazyColumn( Modifier.fillMaxWidth().heightIn(max = 260.dp), - verticalArrangement = Arrangement.spacedBy(Shonar.Space1), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { items(recHits) { hit -> - SonarCard(Modifier.fillMaxWidth(), - onClick = { state.playFrom(detail.file, hit.startSec) }) { - Row(Modifier.padding(Shonar.Space2, Shonar.Space2), + Card(Modifier.fillMaxWidth().clickable { + state.playFrom(detail.file, hit.startSec) + }) { + Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) { Text(fmtTs(hit.startSec), style = MaterialTheme.typography.labelMedium, - color = Shonar.Accent) - Spacer(Modifier.width(Shonar.Space3)) + color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(10.dp)) Text(hit.text, style = MaterialTheme.typography.bodySmall, - color = Shonar.TextPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) @@ -816,7 +736,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 - ShonarGhostButton({ showModels = !showModels }) { + TextButton({ showModels = !showModels }) { Text(if (detail.overrideModel == null) "$currentLabel (default)" else currentLabel) } @@ -833,33 +753,76 @@ fun DetailScreen(state: DesktopState) { } Spacer(Modifier.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, - ) + 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), + ) + } + } + } 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) + 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), + ) } } } @@ -889,21 +852,18 @@ fun DetailScreen(state: DesktopState) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) if (detail.busy == null) { - ShonarButton({ state.summarize() }) { Text("Retry summary") } + Button({ state.summarize() }) { Text("Retry summary") } } } detail.error?.let { - 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") } + Text(it, color = MaterialTheme.colorScheme.error) + if (tJob?.status == "failed") { + Button({ state.retry() }) { Text("Retry transcription") } } } if (detail.busy == null && detail.transcript == null && detail.reportText == null && detail.error == null) { - ShonarButton({ state.transcribe() }) { Text("Transcribe") } + Button({ 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 @@ -930,14 +890,14 @@ fun DetailScreen(state: DesktopState) { var copiedNote by remember(detail.file.name) { mutableStateOf(null) } - Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { - ShonarGhostButton({ + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton({ clipboard.setText(AnnotatedString(s.short)) copiedNote = "Summary copied" }) { Text("Copy summary") } val actionItems = s.list("action_items") if (actionItems.isNotEmpty()) { - ShonarGhostButton({ + TextButton({ clipboard.setText(AnnotatedString( actionItems.joinToString("\n") { "- $it" })) copiedNote = "Action items copied" @@ -960,25 +920,27 @@ 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(Shonar.Space1)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Re-summarize in the voice of", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { SUMMARIZE_PERSONAS.forEach { (label, tone) -> - PillChip( + FilterChip( selected = chipTone == tone, enabled = voicesEnabled, onClick = { sumTone = tone state.summarize(tone) }, - label = label, + label = { Text(label) }, ) } } if (!voicesEnabled && !remoteKnown) { - HelperText("Summarize unlocks once this file is uploaded (press Transcribe).") + Text("Summarize unlocks once this file is uploaded (press Transcribe).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -1023,52 +985,68 @@ fun DetailScreen(state: DesktopState) { val voicesEnabled = remoteKnown && hasTranscript && !sumBusy if (detail.transcript == null) { // Saved-report path: chips here, same rules. - Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { 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(Shonar.Space2)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { SUMMARIZE_PERSONAS.forEach { (label, tone) -> - PillChip( + FilterChip( selected = chipTone == tone, enabled = voicesEnabled, onClick = { sumTone = tone state.summarize(tone) }, - label = label, + label = { Text(label) }, ) } } } } - Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { - ShonarGhostButton({ state.transcribe() }) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton({ 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…") - } } - // 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. + // 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 && pipeline != null) { - val sStage = pipeline.stages.firstOrNull { it.label == "Summary" } + 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()) + } Text(buildString { - 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})") - } + 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})") }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary) } @@ -1094,7 +1072,7 @@ fun DetailScreen(state: DesktopState) { } Spacer(Modifier.weight(1f)) if (editDraft == null && remoteKnownForEdit && detail.busy == null) { - ShonarGhostButton({ + TextButton({ editDraft = if (t.segments.isNotEmpty()) t.segments.joinToString("\n") { it.text } else t.text }) { Text("Edit") } @@ -1116,22 +1094,20 @@ fun DetailScreen(state: DesktopState) { } } } else { - SonarField( + OutlinedTextField( value = draft, onValueChange = { editDraft = it }, - placeholder = "Correct the words…", - singleLine = false, modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp), ) - Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { - ShonarButton( + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( enabled = draft.isNotBlank() && !savingTranscript, onClick = { savingTranscript = true state.saveTranscriptEdit(draft) }, ) { Text(if (savingTranscript) "Saving…" else "Save") } - ShonarOutlinedButton( + OutlinedButton( enabled = !savingTranscript, onClick = { editDraft = null }, ) { Text("Cancel") } @@ -1162,27 +1138,19 @@ fun DetailScreen(state: DesktopState) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } - // Export: pick a folder and a format — the summary + transcript - // leave the library as a file you can keep anywhere. + // Export lives once for both paths (live transcript or saved report). if (detail.transcript != null || detail.reportText != null) { Row(verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { - ShonarOutlinedButton(onClick = { showExport = true }) { - Text("Export…") - } + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { + odtNote = state.exportOdt(detail) + ?: "Saved to ${detail.file.nameWithoutExtension}.report.odt" + }) { Text("Export .odt") } odtNote?.let { Text(it, style = MaterialTheme.typography.bodySmall, - color = Shonar.Accent) + color = MaterialTheme.colorScheme.onSurfaceVariant) } } - 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.) @@ -1214,14 +1182,12 @@ private fun ModelRow( ) { val selected = detail.overrideModel == value || (value == null && detail.overrideModel == null) - SonarCard( - Modifier.fillMaxWidth(), - onClick = { state.setOverride(value) }, + Card( + Modifier.fillMaxWidth().clickable { state.setOverride(value) }, ) { - Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding), + Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { - Text(if (selected) "◉ " else "○ ", - color = if (selected) Shonar.Accent else Shonar.TextMuted) + Text(if (selected) "◉ " else "○ ") Column { Text(title, style = MaterialTheme.typography.bodyMedium) if (subtitle.isNotBlank()) { @@ -1249,36 +1215,38 @@ fun SettingsScreen(state: DesktopState) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { - ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } + TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } Text("Settings", style = MaterialTheme.typography.headlineSmall) } Text("Local engine: $url", style = MaterialTheme.typography.bodySmall) - SectionHeader("Recordings folder") + Text("Recordings folder", style = MaterialTheme.typography.titleMedium) Row(verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { + horizontalArrangement = Arrangement.spacedBy(8.dp)) { Text(folder?.absolutePath ?: "No folder chosen", style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) - ShonarOutlinedButton({ showPicker = true }) { + OutlinedButton({ showPicker = true }) { Text(if (folder == null) "Choose folder…" else "Change folder…") } } - 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", + 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", onClick = { state.setSummarizer("auto") }, - label = "Auto") - PillChip(selected = summarizer == "lan", + label = { Text("Auto") }) + FilterChip(selected = summarizer == "lan", onClick = { state.setSummarizer("lan") }, - label = "LAN server (H200)") - PillChip(selected = summarizer == "local", + label = { Text("LAN server (H200)") }) + FilterChip(selected = summarizer == "local", onClick = { state.setSummarizer("local") }, - label = "This laptop (Ollama)") + label = { Text("This laptop (Ollama)") }) } Text(when (summarizer) { "lan" -> "Best quality; shares the H200 with other clients — " + @@ -1293,9 +1261,11 @@ fun SettingsScreen(state: DesktopState) { }, 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.") + 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) if (models == null) { Row(verticalAlignment = Alignment.CenterVertically) { CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) @@ -1309,24 +1279,22 @@ fun SettingsScreen(state: DesktopState) { color = MaterialTheme.colorScheme.error) } models.models.forEach { m -> - SonarCard(Modifier.fillMaxWidth()) { - Column(Modifier.padding(Shonar.CardPadding), - verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text( (if (m.isDefault) "◉ " else "○ ") + m.displayName, - style = MaterialTheme.typography.titleSmall, - color = if (m.isDefault) Shonar.Accent - else Shonar.TextPrimary, + style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), ) if (!m.isDefault) { - ShonarGhostButton({ state.setDefaultModel(m.name) }) { + TextButton({ state.setDefaultModel(m.name) }) { Text("Set default") } } if (!m.downloaded) { - ShonarGhostButton({ state.downloadModel(m.name) }) { + TextButton({ state.downloadModel(m.name) }) { Text("Download") } } @@ -1354,88 +1322,3 @@ 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 2aa95fe..c78754f 100644 --- a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt @@ -107,124 +107,4 @@ 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 2b387b8..d624331 100644 --- a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt @@ -138,18 +138,11 @@ class ReportsTest { assertTrue("No transcript available." in xml) } - @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() + @Test fun odtFileName() { + val dir = Files.createTempDirectory("odt").toFile() try { val audio = File(dir, "chat.m4a") - 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) + assertEquals("chat.report.odt", DesktopState.odtFile(audio).name) } finally { dir.deleteRecursively() } diff --git a/backend/shonar/api/v1/recordings.py b/backend/shonar/api/v1/recordings.py index 73a745a..65cfaa0 100644 --- a/backend/shonar/api/v1/recordings.py +++ b/backend/shonar/api/v1/recordings.py @@ -86,13 +86,7 @@ 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 6f77c85..35da1af 100644 --- a/backend/shonar/services/processing.py +++ b/backend/shonar/services/processing.py @@ -435,21 +435,6 @@ 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", ""), @@ -564,25 +549,6 @@ 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 7b7a18e..166535c 100644 --- a/backend/tests/test_ai_pipeline.py +++ b/backend/tests/test_ai_pipeline.py @@ -251,45 +251,6 @@ 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 -----------------------------------------------------------------