Compare commits

..

No commits in common. "c32fc2079c44dad6086551c8b774e00e691a9871" and "a3d16a6c1801cf6cf532820df8c3238c8fd56020" have entirely different histories.

8 changed files with 282 additions and 818 deletions

View file

@ -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<JobInfo>): 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<JobInfo>,
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")
}
}
@ -2067,27 +1873,3 @@ 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<PipelineStage>,
val elapsedSec: Long?,
/** Honest ETA: only when derived from a real percentage that measures
* distance (upload/transcribe). Never for summarize. */
val etaSec: Long?,
)

View file

@ -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)

View file

@ -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<String?>(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<String?>(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<String?>(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),
)
}
}
}
}
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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

View file

@ -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

View file

@ -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 -----------------------------------------------------------------