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). */ * an already-transcribed file (no live transcript in memory). */
var reportText: String? = null, var reportText: String? = null,
var error: 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) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@ -922,7 +912,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
_liveProgress.value = _liveProgress.value.toMutableMap().apply { _liveProgress.value = _liveProgress.value.toMutableMap().apply {
remove(old.name) 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() rescan()
return null return null
} }
@ -1361,17 +1351,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
// idle "Transcribe" button for a file already working. // idle "Transcribe" button for a file already working.
if (d.remoteId == null && file.name in inFlight) { if (d.remoteId == null && file.name in inFlight) {
d.busy = _liveProgress.value[file.name]?.label ?: "queued for transcription…" 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) { while (file.name in inFlight && _detail.value?.file == file) {
delay(500) delay(500)
d.busy = _liveProgress.value[file.name]?.label ?: "uploading…" 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 if (_detail.value?.file != file) return@launch
d.busy = null d.busy = null
d.remoteId = loadMapping(file)?.recordingId d.remoteId = loadMapping(file)?.recordingId
d.reportText = runCatching { reportFile(file).readText() }.getOrNull() 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 // Files that predate remote-id mapping have no sidecar: adopt
// the server recording by exact title match so Re-summarize // 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.transcript = t ?: d.transcript
d.summary = s d.summary = s
saveReport(d) 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 // 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 d.jobs = jobs
jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null } 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" } val active = jobs.any { it.status == "running" || it.status == "queued" }
sawActive = sawActive || active sawActive = sawActive || active
if (!active) break if (!active) break
@ -1446,7 +1436,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
d.busy = null d.busy = null
d.reportText = runCatching { reportFile(file).readText() }.getOrNull() d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
?: d.reportText ?: d.reportText
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
rescanStatuses() rescanStatuses()
} }
} }
@ -1479,7 +1469,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} }
fun setOverride(model: String?) { 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() { fun transcribe() {
@ -1493,7 +1483,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
if (d.file.name in inFlight || isRunning(d.file.name)) { if (d.file.name in inFlight || isRunning(d.file.name)) {
d.busy = _liveProgress.value[d.file.name]?.label ?: "Already queued…" d.busy = _liveProgress.value[d.file.name]?.label ?: "Already queued…"
d.error = null d.error = null
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
return return
} }
// A file with a known server recording re-runs the pipeline IN // 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.busy = "Uploading…"
d.uploadProgress = 0f d.uploadProgress = 0f
d.error = null d.error = null
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
val draft = RecordingDraft( val draft = RecordingDraft(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
title = d.file.nameWithoutExtension, title = d.file.nameWithoutExtension,
@ -1538,12 +1528,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
// always change something on screen, immediately. // always change something on screen, immediately.
setLive(d.file.name, LiveProgress("uploading…", p)) setLive(d.file.name, LiveProgress("uploading…", p))
rescanStatuses() rescanStatuses()
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
} }
}.getOrElse { }.getOrElse {
d.busy = null d.busy = null
d.error = it.message ?: "Upload failed." d.error = it.message ?: "Upload failed."
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
return@run return@run
} }
d.remoteId = ref.key d.remoteId = ref.key
@ -1551,7 +1541,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
title = d.file.nameWithoutExtension)) title = d.file.nameWithoutExtension))
d.busy = "Transcribing…" d.busy = "Transcribing…"
d.uploadProgress = null d.uploadProgress = null
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
// Poll jobs until the WHOLE pipeline is terminal. Breaking on // Poll jobs until the WHOLE pipeline is terminal. Breaking on
// transcribe alone froze the screen: the last d.jobs snapshot // transcribe alone froze the screen: the last d.jobs snapshot
// still showed summarize running at 0%, and nothing refreshed // 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. // doesn't make the job look dead.
setLive(d.file.name, jobProgress(jobs)) setLive(d.file.name, jobProgress(jobs))
rescanStatuses() rescanStatuses()
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status val t = jobs.firstOrNull { it.jobType == "transcribe" }?.status
val s = jobs.firstOrNull { it.jobType == "summarize" }?.status val s = jobs.firstOrNull { it.jobType == "summarize" }?.status
if ((t == null || t in TERMINAL) && (s == null || s in TERMINAL)) break 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) { if (failed != null) {
d.busy = null d.busy = null
d.error = failed.error ?: "Transcription failed." d.error = failed.error ?: "Transcription failed."
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
return@run return@run
} }
d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) } d.transcript = runCatching { parseTranscript(provider.fetchTranscript(ref.key)) }
.getOrNull() .getOrNull()
d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull() d.summary = runCatching { parseSummary(provider.fetchSummary(ref.key)) }.getOrNull()
d.busy = null d.busy = null
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
if (d.transcript != null) { if (d.transcript != null) {
saveReport(d) saveReport(d)
rescan() rescan()
} else { } else {
d.error = "Transcription finished but no transcript was returned." 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%" else "Summarizing… 0%"
d0.uploadProgress = null d0.uploadProgress = null
d0.error = null d0.error = null
_detail.value = d0.copy(rev = System.nanoTime()) _detail.value = d0.copy()
runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure { runCatching { provider.reprocess(remoteId, job, model, tone) }.onFailure {
if (job == "transcribe" && it is ProviderError.NotFound) { if (job == "transcribe" && it is ProviderError.NotFound) {
// Mapping points at a deleted recording: drop it so the // 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.remoteId = null
d0.busy = null d0.busy = null
d0.error = "That server recording no longer exists — press Transcribe to upload again." 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 return@launch
} else if (it is ProviderError.Transient && } else if (it is ProviderError.Transient &&
it.message?.contains("already running") == true) { it.message?.contains("already running") == true) {
@ -1638,34 +1628,15 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} else { } else {
d0.busy = null d0.busy = null
d0.error = it.message ?: "Reprocess failed." d0.error = it.message ?: "Reprocess failed."
_detail.value = d0.copy(rev = System.nanoTime()) _detail.value = d0.copy()
return@launch return@launch
} }
} }
// Poll the target job to a terminal state. A job this long // Poll the target job to a terminal state.
// (10+ min = hundreds of polls) WILL hit a transient fetch
// failure; treating one as terminal (the old `orEmpty()` →
// st == null → break) froze the screen on stale content until
// the user left and returned — exactly the reported symptom.
// So: only a SUCCESSFUL fetch reporting a terminal state
// breaks; failures keep polling up to ~30s of contact loss.
var miss = 0
var gone = 0
while (true) { while (true) {
delay(2000) delay(2000)
val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) } val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull()
if (fetched.isFailure) { .orEmpty()
if (++miss >= 15) {
d0.error = "Lost contact with the engine while " +
"processing — reopen this recording to check."
d0.busy = null
_detail.value = d0.copy(rev = System.nanoTime())
return@launch
}
continue
}
miss = 0
val jobs = fetched.getOrDefault(emptyList())
d0.jobs = jobs d0.jobs = jobs
jobLabel(jobs)?.let { d0.busy = it } jobLabel(jobs)?.let { d0.busy = it }
// Mirror live progress onto the library row too — otherwise // 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). // detail screen (the pump path already does this via setLive).
setLive(d0.file.name, jobProgress(jobs)) setLive(d0.file.name, jobProgress(jobs))
rescanStatuses() rescanStatuses()
_detail.value = d0.copy(rev = System.nanoTime()) _detail.value = d0.copy()
val st = jobs.firstOrNull { it.jobType == job }?.status val st = jobs.firstOrNull { it.jobType == job }?.status
if (st in TERMINAL) break if (st == null || st in TERMINAL) break
// Job row missing from a healthy fetch: give it a few
// cycles (the engine rewrites rows mid-requeue) then stop.
if (st == null && ++gone >= 3) break
} }
setLive(d0.file.name, null) setLive(d0.file.name, null)
rescanStatuses() rescanStatuses()
@ -1686,7 +1654,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
if (failed != null) { if (failed != null) {
d0.busy = null d0.busy = null
d0.error = failed.error ?: "That stage failed." d0.error = failed.error ?: "That stage failed."
_detail.value = d0.copy(rev = System.nanoTime()) _detail.value = d0.copy()
return@launch return@launch
} }
d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } 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)) } d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) }
.getOrNull() .getOrNull()
d0.busy = null d0.busy = null
_detail.value = d0.copy(rev = System.nanoTime()) _detail.value = d0.copy()
d0.summary?.let { announceSummaryReady(d0.file, it.version) } d0.summary?.let { announceSummaryReady(d0.file, it.version) }
if (d0.transcript != null || d0.summary != null) { if (d0.transcript != null || d0.summary != null) {
saveReport(d0) saveReport(d0)
@ -1720,7 +1688,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
// opened for a since-renamed/deleted file). Say what's wrong. // opened for a since-renamed/deleted file). Say what's wrong.
d.error = "This file has no server recording to summarize — " + d.error = "This file has no server recording to summarize — " +
"press Transcribe to upload it first." "press Transcribe to upload it first."
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
return return
} }
d.remoteId = remoteId d.remoteId = remoteId
@ -1740,7 +1708,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
if (remoteId == null) { if (remoteId == null) {
d.error = "This file has no server recording to save against — " + d.error = "This file has no server recording to save against — " +
"press Transcribe to upload it first." "press Transcribe to upload it first."
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
return return
} }
d.remoteId = remoteId d.remoteId = remoteId
@ -1748,7 +1716,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
scope.launch { scope.launch {
d.busy = "Saving transcript…" d.busy = "Saving transcript…"
d.error = null d.error = null
_detail.value = d.copy(rev = System.nanoTime()) _detail.value = d.copy()
runCatching { parseTranscript(provider.updateTranscript(remoteId, runCatching { parseTranscript(provider.updateTranscript(remoteId,
org.json.JSONObject().put("text", text).toString())) } org.json.JSONObject().put("text", text).toString())) }
.onSuccess { saved -> .onSuccess { saved ->
@ -1757,13 +1725,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
saveReport(d) saveReport(d)
} }
d.busy = null 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() rescanStatuses()
} }
.onFailure { .onFailure {
d.busy = null d.busy = null
d.error = it.message ?: "Transcript save failed." 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)) reportFile(d.file).writeText(renderReport(d.file.nameWithoutExtension, d.transcript, d.summary))
} }
/** Export summary + transcript into [dir] in "md" or "odt" format. /** Write the .odt export next to the audio (same base name as the
* Returns null on success (caller shows the path) or a human * .md report, .report.odt extension). Returns null on success or a
* reason it failed. Never touches the library's own report file. */ * human reason it failed. */
fun exportTo(d: DetailUi, dir: File, format: String): String? { fun exportOdt(d: DetailUi): String? {
if (!dir.isDirectory) return "That folder no longer exists." val out = odtFile(d.file)
val base = d.file.nameWithoutExtension return runCatching {
val report = d.reportText out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary))
return when (format) { }.fold(
"md" -> { onSuccess = { null },
val out = File(dir, "$base.transcript.md") onFailure = { "Couldn't save ${out.name}: ${it.message}" },
val text = d.transcript?.let { )
renderReport(base, it, d.summary)
} ?: report
if (text == null) "Nothing to export yet — no summary or transcript."
else runCatching { out.writeText(text) }.fold(
onSuccess = { null },
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
)
}
"odt" -> {
if (d.transcript == null && d.summary == null && report == null)
"Nothing to export yet — no summary or transcript."
else {
val out = File(dir, "$base.report.odt")
runCatching {
out.writeBytes(renderOdt(base, d.transcript, d.summary))
}.fold(
onSuccess = { null },
onFailure = { "Couldn't save ${out.name}: ${it.message}" },
)
}
}
else -> "Unknown export format."
}
} }
companion object { 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 /** " · 1:23" of wait time since a job started running, "" when
* under 5s or the start time is unknown. The llama-swap backend * under 5s or the start time is unknown. The llama-swap backend
* exposes no queue depth, so elapsed wait is the most truthful * 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. */ /** Label-only convenience for the Detail screen's busy text. */
fun jobLabel(jobs: List<JobInfo>): String? = jobProgress(jobs)?.label 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_URL = "server.url"
const val KEY_FOLDER = "library.folder" const val KEY_FOLDER = "library.folder"
const val KEY_PASSWORD = "local.password" const val KEY_PASSWORD = "local.password"
@ -2054,6 +1857,9 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
fun reportFile(audio: File): File = fun reportFile(audio: File): File =
File(audio.parentFile, "${audio.nameWithoutExtension}.transcript.md") 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. */ /** What the pipeline is doing for a library file right now. */
data class LiveProgress(val label: String, val fraction: Float?) 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -152,39 +155,41 @@ fun main() = application {
val summaryToast by state.summaryToast.collectAsState() val summaryToast by state.summaryToast.collectAsState()
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
summaryToast?.let { t -> summaryToast?.let { t ->
SonarCard( Card(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer),
) { ) {
Row(Modifier.fillMaxWidth() Row(Modifier.fillMaxWidth()
.padding(horizontal = Shonar.CardPadding), .padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(t.label, Text(t.label,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
ShonarGhostButton({ state.viewSummaryReady(t) }) { TextButton({ state.viewSummaryReady(t) }) {
Text("View") Text("View")
} }
ShonarGhostButton({ state.dismissSummaryToast() }) { TextButton({ state.dismissSummaryToast() }) {
Text("✕") Text("✕")
} }
} }
} }
Spacer(Modifier.height(Shonar.Space2)) Spacer(Modifier.height(8.dp))
} }
undoToast?.let { t -> undoToast?.let { t ->
SonarCard(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth() Row(Modifier.fillMaxWidth()
.padding(horizontal = Shonar.CardPadding), .padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(t.label, Text(t.label,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
ShonarGhostButton({ state.undoLastTrash() }) { TextButton({ state.undoLastTrash() }) {
Text("Undo") Text("Undo")
} }
} }
} }
Spacer(Modifier.height(Shonar.Space2)) Spacer(Modifier.height(8.dp))
} }
when (screen) { when (screen) {
DesktopState.Screen.ENGINE -> EngineScreen(state) DesktopState.Screen.ENGINE -> EngineScreen(state)

View file

@ -1,5 +1,6 @@
package com.shonar.desktop package com.shonar.desktop
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.Settings
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog 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.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.material3.TextButton
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -76,9 +82,9 @@ fun EngineScreen(state: DesktopState) {
} }
else -> { else -> {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShonarButton({ state.startEngine() }) { Text("Start engine") } Button({ state.startEngine() }) { Text("Start engine") }
ShonarOutlinedButton({ state.ensureReady() }) { Text("Retry") } OutlinedButton({ state.ensureReady() }) { Text("Retry") }
} }
} }
} }
@ -135,11 +141,11 @@ fun FolderPickerDialog(
verticalArrangement = Arrangement.spacedBy(6.dp)) { verticalArrangement = Arrangement.spacedBy(6.dp)) {
// Breadcrumb row: home + each step; click to jump. // Breadcrumb row: home + each step; click to jump.
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
ShonarGhostButton({ current = home }) { Text("Home") } TextButton({ current = home }) { Text("Home") }
crumbs.dropWhile { it.absolutePath == home.absolutePath } crumbs.dropWhile { it.absolutePath == home.absolutePath }
.forEach { d -> .forEach { d ->
Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant) Text("/", color = MaterialTheme.colorScheme.onSurfaceVariant)
ShonarGhostButton({ current = d }) { TextButton({ current = d }) {
Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1) Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1)
} }
} }
@ -149,7 +155,7 @@ fun FolderPickerDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
HorizontalDivider() HorizontalDivider()
if (current.parentFile != null) { if (current.parentFile != null) {
ShonarGhostButton( TextButton(
{ current.parentFile?.let { current = it } }, { current.parentFile?.let { current = it } },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("⋱ .. (go up)") } ) { Text("⋱ .. (go up)") }
@ -161,31 +167,31 @@ fun FolderPickerDialog(
} }
LazyColumn(Modifier.weight(1f)) { LazyColumn(Modifier.weight(1f)) {
items(subs, key = { it.absolutePath }) { d -> items(subs, key = { it.absolutePath }) { d ->
SonarCard(Modifier.fillMaxWidth(), Card(Modifier.fillMaxWidth().clickable { current = d }) {
onClick = { current = d }) { Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp,
Row(Modifier.fillMaxWidth().padding(horizontal = Shonar.CardPadding, vertical = 8.dp),
vertical = Shonar.Space2),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text("▸", color = MaterialTheme.colorScheme.primary) Text("▸", color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(Shonar.Space2)) Spacer(Modifier.width(8.dp))
Text(d.name, style = MaterialTheme.typography.bodyMedium) Text(d.name, style = MaterialTheme.typography.bodyMedium)
} }
} }
Spacer(Modifier.height(Shonar.Space1)) Spacer(Modifier.height(4.dp))
} }
} }
error?.let { error?.let {
HelperText(it, error = true) Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
} }
} }
}, },
confirmButton = { confirmButton = {
ShonarGhostButton({ TextButton({
if (current.isDirectory) onResult(current) if (current.isDirectory) onResult(current)
else error = "That folder no longer exists." else error = "That folder no longer exists."
}) { Text("Choose “${current.name.ifEmpty { current.absolutePath }}”") } }) { 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 ----------------------------------------------------------------- // ---- detail -----------------------------------------------------------------
/** Personalities offered beside Re-summarize: (label, tone sent to the /** 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 savingTranscript by remember(detail.file.name) { mutableStateOf(false) }
var odtNote by remember(detail.file.name) { mutableStateOf<String?>(null) } 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. // The save (or its failure) clears busy — release the button lock.
LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false } LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false }
val playingFile by state.playingFile.collectAsState() val playingFile by state.playingFile.collectAsState()
@ -683,11 +611,6 @@ fun DetailScreen(state: DesktopState) {
val chipTone: String? = when { val chipTone: String? = when {
sJobNow != null && sJobNow.status in setOf("queued", "running") -> sJobNow.tone sJobNow != null && sJobNow.status in setOf("queued", "running") -> sJobNow.tone
sSummary != null -> sSummary.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 else -> sumTone
} }
@ -696,28 +619,29 @@ fun DetailScreen(state: DesktopState) {
// the complaint. Everything below scrolls as one flow. // the complaint. Everything below scrolls as one flow.
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
ShonarGhostButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
Text(detail.file.nameWithoutExtension, Text(detail.file.nameWithoutExtension,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
// Page through recordings without returning to the list // Page through recordings without returning to the list
// (Alt+←/→ do the same). Disabled honestly at the ends. // (Alt+←/→ do the same). Disabled honestly at the ends.
ShonarGhostButton( TextButton(
onClick = { state.neighborOf(detail.file, forward = false) onClick = { state.neighborOf(detail.file, forward = false)
?.let(state::openDetail) }, ?.let(state::openDetail) },
enabled = state.neighborOf(detail.file, forward = false) != null, enabled = state.neighborOf(detail.file, forward = false) != null,
) { Text("← Prev") } ) { Text("← Prev") }
ShonarGhostButton( TextButton(
onClick = { state.neighborOf(detail.file, forward = true) onClick = { state.neighborOf(detail.file, forward = true)
?.let(state::openDetail) }, ?.let(state::openDetail) },
enabled = state.neighborOf(detail.file, forward = true) != null, enabled = state.neighborOf(detail.file, forward = true) != null,
) { Text("Next →") } ) { Text("Next →") }
ShonarDangerButton( TextButton(
{ confirmDelete = true }, { confirmDelete = true },
enabled = detail.busy == null, enabled = detail.busy == null,
) { content = {
Text("Delete") Text("Delete", color = MaterialTheme.colorScheme.error)
} },
)
} }
Column(Modifier.fillMaxWidth().weight(1f) Column(Modifier.fillMaxWidth().weight(1f)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
@ -733,10 +657,10 @@ fun DetailScreen(state: DesktopState) {
// One Stop control, always present (the pause/resume pill // One Stop control, always present (the pause/resume pill
// was redundant: Stop already ends playback, and the // was redundant: Stop already ends playback, and the
// separate Stop text button duplicated it). // separate Stop text button duplicated it).
ShonarButton({ state.stopPlayback() }) { Button({ state.stopPlayback() }) {
Icon(Icons.Filled.Stop, contentDescription = null, Icon(Icons.Filled.Stop, contentDescription = null,
modifier = Modifier.size(Shonar.IconSm)) modifier = Modifier.size(18.dp))
Spacer(Modifier.width(Shonar.Space2)) Spacer(Modifier.width(6.dp))
Text("Stop") Text("Stop")
} }
if (transport && ps.durationSec > 0) { if (transport && ps.durationSec > 0) {
@ -755,27 +679,23 @@ fun DetailScreen(state: DesktopState) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
} else { } else {
ShonarOutlinedButton({ state.playFrom(detail.file) }) { OutlinedButton({ state.playFrom(detail.file) }) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, Icon(Icons.Filled.PlayArrow, contentDescription = null,
modifier = Modifier.size(Shonar.IconSm)) modifier = Modifier.size(18.dp))
Spacer(Modifier.width(Shonar.Space2)) Spacer(Modifier.width(6.dp))
Text("Play") Text("Play")
} }
} }
playError?.let { 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. // Search within this recording: hits jump the audio to that moment.
if (transcriptLines.isNotEmpty()) { if (transcriptLines.isNotEmpty()) {
SonarField(recQuery, { recQuery = it }, OutlinedTextField(recQuery, { recQuery = it },
placeholder = "Search this recording…", label = { Text("Search this recording") },
singleLine = true, modifier = Modifier.fillMaxWidth(), singleLine = true, modifier = Modifier.fillMaxWidth())
leadingIcon = {
Icon(Icons.Filled.Search, contentDescription = null,
tint = Shonar.TextSecondary,
modifier = Modifier.size(Shonar.IconSm))
})
if (recQuery.isNotBlank()) { if (recQuery.isNotBlank()) {
if (recHits.isEmpty()) { if (recHits.isEmpty()) {
Text("No matches in this recording.", Text("No matches in this recording.",
@ -787,20 +707,20 @@ fun DetailScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
LazyColumn( LazyColumn(
Modifier.fillMaxWidth().heightIn(max = 260.dp), Modifier.fillMaxWidth().heightIn(max = 260.dp),
verticalArrangement = Arrangement.spacedBy(Shonar.Space1), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
items(recHits) { hit -> items(recHits) { hit ->
SonarCard(Modifier.fillMaxWidth(), Card(Modifier.fillMaxWidth().clickable {
onClick = { state.playFrom(detail.file, hit.startSec) }) { state.playFrom(detail.file, hit.startSec)
Row(Modifier.padding(Shonar.Space2, Shonar.Space2), }) {
Row(Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(fmtTs(hit.startSec), Text(fmtTs(hit.startSec),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = Shonar.Accent) color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(Shonar.Space3)) Spacer(Modifier.width(10.dp))
Text(hit.text, Text(hit.text,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Shonar.TextPrimary,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
@ -816,7 +736,7 @@ fun DetailScreen(state: DesktopState) {
Text("Model: ", style = MaterialTheme.typography.bodyMedium) Text("Model: ", style = MaterialTheme.typography.bodyMedium)
val current = detail.overrideModel ?: models?.defaultModel ?: "base" val current = detail.overrideModel ?: models?.defaultModel ?: "base"
val currentLabel = models?.byName(current)?.displayName ?: current val currentLabel = models?.byName(current)?.displayName ?: current
ShonarGhostButton({ showModels = !showModels }) { TextButton({ showModels = !showModels }) {
Text(if (detail.overrideModel == null) Text(if (detail.overrideModel == null)
"$currentLabel (default)" else currentLabel) "$currentLabel (default)" else currentLabel)
} }
@ -833,33 +753,76 @@ fun DetailScreen(state: DesktopState) {
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
// Processing pipeline: real stages only. Each row flips when the detail.uploadProgress?.let {
// engine actually moves; percentages appear only where the backend LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth())
// measures distance (upload bytes, transcribe audio-time). No Text("Uploading… ${(it * 100).toInt()}%")
// decorative motion pretending to be progress. }
val pipeline = DesktopState.pipelineStatus( detail.busy?.takeIf { detail.uploadProgress == null }?.let {
uploadProgress = detail.uploadProgress, Row(verticalAlignment = Alignment.CenterVertically) {
remoteKnown = state.remoteIdFor(detail.file) != null, CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
hasTranscript = detail.transcript != null, Spacer(Modifier.width(10.dp))
hasSummary = detail.summary != null, Text(it)
jobs = detail.jobs, }
) }
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" } val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
if (pipeline != null) { sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let {
PipelinePanel(pipeline) // Honest milestone progress: the engine counts completed
} else if (detail.busy != null) { // contract units (JSON keys/items finished), so the number
// Click-landed but no job row yet (POST/token/poll latency): // means real work done — a determinate bar is truthful now.
// busy is REAL work in flight — showing nothing here was the val toneWord = sJob.tone?.lowercase()?.replace(",", "")
// "hit transcribe, screen looks dead" blind window. ?.takeIf { it.isNotBlank() && it != "neutral" }
SonarCard(modifier = Modifier.fillMaxWidth()) { val label = when {
Row( it.status == "running" && it.progress != null && it.progress > 0 ->
verticalAlignment = Alignment.CenterVertically, "Summarizing" + (toneWord?.let { t -> " $t" } ?: "") +
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2), "… ${it.progress}%" +
) { DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
Text("→", style = MaterialTheme.typography.bodySmall, it.status == "running" -> "Summarizing… 0% waiting for model" +
color = MaterialTheme.colorScheme.primary) DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
Text(detail.busy!!, style = MaterialTheme.typography.bodySmall, else -> "Summary queued" +
color = MaterialTheme.colorScheme.primary) 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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
if (detail.busy == null) { if (detail.busy == null) {
ShonarButton({ state.summarize() }) { Text("Retry summary") } Button({ state.summarize() }) { Text("Retry summary") }
} }
} }
detail.error?.let { detail.error?.let {
HelperText(it, error = true) Text(it, color = MaterialTheme.colorScheme.error)
val tFailed = detail.jobs.firstOrNull { j -> if (tJob?.status == "failed") {
j.jobType == "transcribe" && j.status == "failed" Button({ state.retry() }) { Text("Retry transcription") }
}
if (tFailed != null) {
ShonarButton({ state.retry() }) { Text("Retry transcription") }
} }
} }
if (detail.busy == null && detail.transcript == null && if (detail.busy == null && detail.transcript == null &&
detail.reportText == null && detail.error == 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 // Summary ABOVE the transcript: a recording with a long transcript
// pushed it below the fold (the transcript region is the only // 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) { var copiedNote by remember(detail.file.name) {
mutableStateOf<String?>(null) mutableStateOf<String?>(null)
} }
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShonarGhostButton({ TextButton({
clipboard.setText(AnnotatedString(s.short)) clipboard.setText(AnnotatedString(s.short))
copiedNote = "Summary copied" copiedNote = "Summary copied"
}) { Text("Copy summary") } }) { Text("Copy summary") }
val actionItems = s.list("action_items") val actionItems = s.list("action_items")
if (actionItems.isNotEmpty()) { if (actionItems.isNotEmpty()) {
ShonarGhostButton({ TextButton({
clipboard.setText(AnnotatedString( clipboard.setText(AnnotatedString(
actionItems.joinToString("\n") { "- $it" })) actionItems.joinToString("\n") { "- $it" }))
copiedNote = "Action items copied" copiedNote = "Action items copied"
@ -960,25 +920,27 @@ fun DetailScreen(state: DesktopState) {
val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" } val sumBusy = detail.jobs.firstOrNull { it.jobType == "summarize" }
?.status in setOf("queued", "running") ?.status in setOf("queued", "running")
val voicesEnabled = remoteKnown && hasTranscript && !sumBusy val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Re-summarize in the voice of", Text("Re-summarize in the voice of",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SUMMARIZE_PERSONAS.forEach { (label, tone) -> SUMMARIZE_PERSONAS.forEach { (label, tone) ->
PillChip( FilterChip(
selected = chipTone == tone, selected = chipTone == tone,
enabled = voicesEnabled, enabled = voicesEnabled,
onClick = { onClick = {
sumTone = tone sumTone = tone
state.summarize(tone) state.summarize(tone)
}, },
label = label, label = { Text(label) },
) )
} }
} }
if (!voicesEnabled && !remoteKnown) { 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 val voicesEnabled = remoteKnown && hasTranscript && !sumBusy
if (detail.transcript == null) { if (detail.transcript == null) {
// Saved-report path: chips here, same rules. // 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" Text(if (hasSummary) "Re-summarize in the voice of"
else "Summarize in the voice of", else "Summarize in the voice of",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SUMMARIZE_PERSONAS.forEach { (label, tone) -> SUMMARIZE_PERSONAS.forEach { (label, tone) ->
PillChip( FilterChip(
selected = chipTone == tone, selected = chipTone == tone,
enabled = voicesEnabled, enabled = voicesEnabled,
onClick = { onClick = {
sumTone = tone sumTone = tone
state.summarize(tone) state.summarize(tone)
}, },
label = label, label = { Text(label) },
) )
} }
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShonarGhostButton({ state.transcribe() }) { TextButton({ state.transcribe() }) {
Text(if (hasTranscript) "Re-transcribe" else "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 // Summarize progress right where the buttons are: a running
// mirroring the pipeline panel's Summary stage (the panel may // summarize must be visible next to the chips that started it.
// be scrolled out of view when the chips get clicked). No val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
// bar animation: the engine reports stage, not distance.
val sRunning = sJob?.status in setOf("queued", "running") val sRunning = sJob?.status in setOf("queued", "running")
if (sRunning && pipeline != null) { if (sRunning) {
val sStage = pipeline.stages.firstOrNull { it.label == "Summary" } 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 { Text(buildString {
append("→ ").append(sStage?.note append(when {
?: if (sJob?.status == "queued") "queued on the summarizer" sStatus == "queued" -> "Queued…" +
else "writing") DesktopState.waitSuffix(sJob?.createdAt,
pipeline.elapsedSec?.let { append(" · ${DesktopState.fmtMMSS(it)} elapsed") } System.currentTimeMillis())
(sJob?.attempt ?: 0).let { sProg != null && sProg > 0 ->
if (it > 0) append(" (attempt $it/${sJob?.maxAttempts})") "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, }, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary) color = MaterialTheme.colorScheme.primary)
} }
@ -1094,7 +1072,7 @@ fun DetailScreen(state: DesktopState) {
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (editDraft == null && remoteKnownForEdit && detail.busy == null) { if (editDraft == null && remoteKnownForEdit && detail.busy == null) {
ShonarGhostButton({ TextButton({
editDraft = if (t.segments.isNotEmpty()) editDraft = if (t.segments.isNotEmpty())
t.segments.joinToString("\n") { it.text } else t.text t.segments.joinToString("\n") { it.text } else t.text
}) { Text("Edit") } }) { Text("Edit") }
@ -1116,22 +1094,20 @@ fun DetailScreen(state: DesktopState) {
} }
} }
} else { } else {
SonarField( OutlinedTextField(
value = draft, value = draft,
onValueChange = { editDraft = it }, onValueChange = { editDraft = it },
placeholder = "Correct the words…",
singleLine = false,
modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp),
) )
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShonarButton( Button(
enabled = draft.isNotBlank() && !savingTranscript, enabled = draft.isNotBlank() && !savingTranscript,
onClick = { onClick = {
savingTranscript = true savingTranscript = true
state.saveTranscriptEdit(draft) state.saveTranscriptEdit(draft)
}, },
) { Text(if (savingTranscript) "Saving…" else "Save") } ) { Text(if (savingTranscript) "Saving…" else "Save") }
ShonarOutlinedButton( OutlinedButton(
enabled = !savingTranscript, enabled = !savingTranscript,
onClick = { editDraft = null }, onClick = { editDraft = null },
) { Text("Cancel") } ) { Text("Cancel") }
@ -1162,27 +1138,19 @@ fun DetailScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
// Export: pick a folder and a format — the summary + transcript // Export lives once for both paths (live transcript or saved report).
// leave the library as a file you can keep anywhere.
if (detail.transcript != null || detail.reportText != null) { if (detail.transcript != null || detail.reportText != null) {
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ShonarOutlinedButton(onClick = { showExport = true }) { OutlinedButton(onClick = {
Text("Export…") odtNote = state.exportOdt(detail)
} ?: "Saved to ${detail.file.nameWithoutExtension}.report.odt"
}) { Text("Export .odt") }
odtNote?.let { odtNote?.let {
Text(it, style = MaterialTheme.typography.bodySmall, 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 // (Voice chips + Re-transcribe + summarize progress live above the
// transcript, right under the summary.) // transcript, right under the summary.)
@ -1214,14 +1182,12 @@ private fun ModelRow(
) { ) {
val selected = detail.overrideModel == value || val selected = detail.overrideModel == value ||
(value == null && detail.overrideModel == null) (value == null && detail.overrideModel == null)
SonarCard( Card(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth().clickable { state.setOverride(value) },
onClick = { state.setOverride(value) },
) { ) {
Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding), Row(Modifier.fillMaxWidth().padding(10.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text(if (selected) "◉ " else "○ ", Text(if (selected) "◉ " else "○ ")
color = if (selected) Shonar.Accent else Shonar.TextMuted)
Column { Column {
Text(title, style = MaterialTheme.typography.bodyMedium) Text(title, style = MaterialTheme.typography.bodyMedium)
if (subtitle.isNotBlank()) { if (subtitle.isNotBlank()) {
@ -1249,36 +1215,38 @@ fun SettingsScreen(state: DesktopState) {
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)) { verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { 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("Settings", style = MaterialTheme.typography.headlineSmall)
} }
Text("Local engine: $url", style = MaterialTheme.typography.bodySmall) Text("Local engine: $url", style = MaterialTheme.typography.bodySmall)
SectionHeader("Recordings folder") Text("Recordings folder", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(folder?.absolutePath ?: "No folder chosen", Text(folder?.absolutePath ?: "No folder chosen",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)) modifier = Modifier.weight(1f))
ShonarOutlinedButton({ showPicker = true }) { OutlinedButton({ showPicker = true }) {
Text(if (folder == null) "Choose folder…" else "Change folder…") Text(if (folder == null) "Choose folder…" else "Change folder…")
} }
} }
Spacer(Modifier.height(Shonar.Space1)) Spacer(Modifier.height(4.dp))
SectionHeader("Summarizer") Text("Summarizer", style = MaterialTheme.typography.titleMedium)
HelperText("Where summaries are written. Restarting the engine applies " + Text("Where summaries are written. Restarting the engine applies " +
"the choice; work already running carries over.") "the choice; work already running carries over.",
Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) { style = MaterialTheme.typography.bodySmall,
PillChip(selected = summarizer == "auto", color = MaterialTheme.colorScheme.onSurfaceVariant)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = summarizer == "auto",
onClick = { state.setSummarizer("auto") }, onClick = { state.setSummarizer("auto") },
label = "Auto") label = { Text("Auto") })
PillChip(selected = summarizer == "lan", FilterChip(selected = summarizer == "lan",
onClick = { state.setSummarizer("lan") }, onClick = { state.setSummarizer("lan") },
label = "LAN server (H200)") label = { Text("LAN server (H200)") })
PillChip(selected = summarizer == "local", FilterChip(selected = summarizer == "local",
onClick = { state.setSummarizer("local") }, onClick = { state.setSummarizer("local") },
label = "This laptop (Ollama)") label = { Text("This laptop (Ollama)") })
} }
Text(when (summarizer) { Text(when (summarizer) {
"lan" -> "Best quality; shares the H200 with other clients — " + "lan" -> "Best quality; shares the H200 with other clients — " +
@ -1293,9 +1261,11 @@ fun SettingsScreen(state: DesktopState) {
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(Shonar.Space1)) Spacer(Modifier.height(4.dp))
SectionHeader("Default transcription model") Text("Default transcription model", style = MaterialTheme.typography.titleMedium)
HelperText("Applies to future transcriptions. Each recording keeps the model it used.") Text("Applies to future transcriptions. Each recording keeps the model it used.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
if (models == null) { if (models == null) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
@ -1309,24 +1279,22 @@ fun SettingsScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.error) color = MaterialTheme.colorScheme.error)
} }
models.models.forEach { m -> models.models.forEach { m ->
SonarCard(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(Shonar.CardPadding), Column(Modifier.padding(10.dp),
verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
(if (m.isDefault) "◉ " else "○ ") + m.displayName, (if (m.isDefault) "◉ " else "○ ") + m.displayName,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.bodyMedium,
color = if (m.isDefault) Shonar.Accent
else Shonar.TextPrimary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (!m.isDefault) { if (!m.isDefault) {
ShonarGhostButton({ state.setDefaultModel(m.name) }) { TextButton({ state.setDefaultModel(m.name) }) {
Text("Set default") Text("Set default")
} }
} }
if (!m.downloaded) { if (!m.downloaded) {
ShonarGhostButton({ state.downloadModel(m.name) }) { TextButton({ state.downloadModel(m.name) }) {
Text("Download") 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. // visible time too, so it must count from created_at.
assertEquals(" · 2:04", DesktopState.waitSuffix(iso, t0)) 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) assertTrue("No transcript available." in xml)
} }
@Test fun odtExportFilenamesThroughExportTo() { @Test fun odtFileName() {
// exportTo is the only export path now; check both formats land val dir = Files.createTempDirectory("odt").toFile()
// with the promised names in the chosen folder.
val dir = Files.createTempDirectory("exp").toFile()
try { try {
val audio = File(dir, "chat.m4a") val audio = File(dir, "chat.m4a")
val state = DesktopState(Files.createTempDirectory("expstate").toFile()) assertEquals("chat.report.odt", DesktopState.odtFile(audio).name)
val detail = DesktopState.DetailUi(audio)
// No data yet -> honest refusal, no file written.
assertTrue(state.exportTo(detail, dir, "odt") != null)
assertTrue(state.exportTo(detail, dir, "md") != null)
assertTrue(File(dir, "chat.report.odt").exists() == false)
} finally { } finally {
dir.deleteRecursively() 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, session, user.id, body.declared_mime_type, body.declared_size_bytes,
body.title, body.client_recording_id, body.transcription_model, 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: except up.UploadError as e:
await session.rollback()
raise HTTPException(e.status_code, e.message) from None raise HTTPException(e.status_code, e.message) from None
return us 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 _fail(session, rec, job, str(e), ctx, e)
await session.commit() await session.commit()
return 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( await store_transcript(
session, rec, result.text, result.segments, result.language, session, rec, result.text, result.segments, result.language,
provider.name, getattr(result, "model", ""), provider.name, getattr(result, "model", ""),
@ -564,25 +549,6 @@ async def run_summarize(ctx: dict, recording_id: str) -> None:
await session.commit() await session.commit()
return return
result, result_provider, fallback_note = rescue 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, await store_summary(session, rec, result.to_dict(), result_provider,
result.model, tone=job.tone) result.model, tone=job.tone)
job.status = JobStatus.succeeded job.status = JobStatus.succeeded

View file

@ -251,45 +251,6 @@ async def test_config_error_fails_fast(client, monkeypatch):
assert stt.calls == 1 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 ----------------------------------------------------------------- # --- versioning -----------------------------------------------------------------