Compare commits

...

10 commits

Author SHA1 Message Date
avi
c32fc2079c PipelinePanel: wrap card content in a Column so stages stop painting over the headline
SonarCard's Surface lays un-wrapped siblings in a Box, so the whole
processing panel (teal headline, gray elapsed/note text, stage rows,
progress bar) drew stacked on one line — the 'text overlapping while
summarizing' garble, visible only mid-job. Every other card call site
wraps its content; this was the one that didn't. Headline also gets
weight(1f, fill=false) + maxLines/Ellipsis so it can't collide with
the elapsed/ETA text at narrow widths.
2026-09-20 20:49:21 -05:00
avi
514d75fe92 Raw provider crash fails the job instead of escaping into the sweep requeue loop
A non-AIError from transcribe/summarize (av.InvalidDataError on corrupt
audio, seen live with 'My recording 63') escaped the consumer, left the
row 'running', and got requeued at every engine restart forever. Both
runners now catch it, fail the row with the exception type in the
message, and keep a usable transcript (summarize completes with a
'Summary failed' note). Regression tests pin both paths (proven red
without the fix).
2026-09-19 15:24:50 -05:00
avi
15381b2c41 Commit upload session row before responding; client's follow-up GET raced the post-response commit into a 404 2026-09-18 23:17:04 -05:00
avi
b541cc2314 Never swallow detail-screen updates: revision field on DetailUi
THE root cause behind "summary changed but the open screen kept the
old voice until I left to the dashboard and back": the app mutates ONE
DetailUi instance in place and republishes it with copy(). StateFlow
drops any assignment equal() to its current value — and a copy of the
very object it already holds is ALWAYS equal (same mutated fields).
So every publish after the first was silently dropped. The screen
only ever refreshed by accident, when some other collected flow
(models, playback, library rows) happened to trigger a recomposition
that re-read the mutated fields mid-run; the final summary publish,
with nothing else changing at that moment, just vanished. Engine
logs proved the app fetched the fresh sarcastic summary — the UI
received it and threw it away.

DetailUi gains a rev discriminator bumped on every publish (23 sites),
so no update can ever conflate away. Two regression tests pin the
conflation semantics.
2026-09-18 22:05:04 -05:00
avi
05b84a361a Show the busy label the instant a click lands, before the first job row exists
Between pressing Transcribe and the first /jobs poll there was a blind
window where real work (POST in flight, token refresh, initial upload)
had no on-screen trace at all — the screenshot complaint 'shows
nothing'. Any non-null busy label now renders as a pipeline-style card
in the same slot the panel occupies, so feedback starts at click time.
2026-09-18 21:18:30 -05:00
avi
b5836d0fb6 Keep watching the job through transient fetch failures; don't freeze the screen
reprocessInPlace's poll loop swallowed fetchJobs failures as an empty
list and broke on the resulting missing status, so ONE dropped poll
during a 10-minute summarize ended the watch early: chips stayed
disabled, the screen showed stale content, and only leaving to the
library and back (fresh openDetail fetch) displayed the finished
summary — the exact reported symptom.

Now only a successful fetch reporting a terminal state ends the loop;
transient failures keep polling for ~30s, and a genuinely-lost engine
says so in plain words instead of silently stranding the screen. A
missing job row on healthy fetches gets 3 grace cycles before giving
up (engine rewrites rows mid-requeue).
2026-09-18 21:11:58 -05:00
avi
70483b4cf8 Voice chips follow the saved report's voice in saved-report view
Opening a recording whose live summary row isn't loaded fell through
to sumTone (null = Neutral), so the pill showed Neutral while the
report text on screen read "*Voice: funny*". chipTone now reads the
report's own *Voice: X* metadata line as the fallback, so the chips
can never contradict the artifact being displayed. Running job and
live summary rows still take precedence. 3 new tests (reportVoice).
2026-09-18 19:58:42 -05:00
avi
1a6acd92d6 Honest processing pipeline panel: real stages, real percent, real elapsed
Replaces the animated bars that moved without meaning anything with a
stage panel wired to engine truth only:

- Upload: real bytes fraction (backend measures it).
- Transcription: real audio-time percent + ETA (15-95% window only;
  outside it the ETA is withheld as noise).
- Summary: stage markers only — the engine's summarize percent means
  "streaming" (thinking models hit 99 on the first token), so no pct
  and no ETA are shown for it, ever. "waiting for model" vs "writing
  in a dry, witty voice · streaming reply" vs "queued on the
  summarizer" come from the actual job row.
- Finalizing: engine done, app hasn't landed the fresh result yet.
- Header shows elapsed (and ETA where derivable) counting from the
  job's real started_at, parsed as server UTC.
- The line beside the voice chips mirrors the Summary stage so a
  clicked chip always gets visible feedback even if the panel is
  scrolled away.

Rows flip only when the engine actually moves a stage. No timers,
no decoration pretending to be progress. 6 new unit tests pin the
contract (65 frontend tests, 0 failures).
2026-09-18 19:46:17 -05:00
avi
8dc74a98a9 Export button at the top of the detail actions row too
The long saved report pushed the bottom Export out of sight. It now
sits beside Re-transcribe right under the voice chips, and stays at
the bottom for the end-of-document flow.
2026-09-18 18:45:50 -05:00
avi
b5113f4abc Detail, Settings, Engine, dialogs, and toasts on the design system (phase 4-5)
Every remaining screen routes through the shared primitives: Detail's
header nav (ghost links + quiet-red Delete), playback transport,
in-recording search field, voice PillChips, model picker and transcript
edit; Settings' folder row, summarizer PillChips, and model cards
(default model gets the teal ◉); Engine's start/retry; the folder
picker's breadcrumbs and rows; and both app-wide toasts are SonarCards.
Export becomes a real dialog: pick Markdown or ODT + any folder
(exportTo in DesktopState — the library's own .transcript.md is never
touched). Unused Material widget imports removed app-wide.

Verified live: library screenshot shows the reference language end to
end (glowing selected pills, hairline cards, teal status text);
59 frontend tests green.
2026-09-18 18:39:56 -05:00
8 changed files with 818 additions and 282 deletions

View file

@ -66,6 +66,16 @@ 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)
@ -912,7 +922,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) if (_detail.value?.file == old) _detail.value = _detail.value?.copy(file = target, rev = System.nanoTime())
rescan() rescan()
return null return null
} }
@ -1351,17 +1361,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() if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
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() if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
} }
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() if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
} }
// 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
@ -1386,7 +1396,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() if (_detail.value?.file == file) _detail.value = d.copy(rev = System.nanoTime())
} }
} }
// If the file is mid-pipeline (pump running, or reprocess started // If the file is mid-pipeline (pump running, or reprocess started
@ -1412,7 +1422,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} }
d.jobs = jobs 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() _detail.value = d.copy(rev = System.nanoTime())
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
@ -1436,7 +1446,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() _detail.value = d.copy(rev = System.nanoTime())
rescanStatuses() rescanStatuses()
} }
} }
@ -1469,7 +1479,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() } _detail.value?.let { it.overrideModel = model; _detail.value = it.copy(rev = System.nanoTime()) }
} }
fun transcribe() { fun transcribe() {
@ -1483,7 +1493,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() _detail.value = d.copy(rev = System.nanoTime())
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
@ -1509,7 +1519,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() _detail.value = d.copy(rev = System.nanoTime())
val draft = RecordingDraft( val draft = RecordingDraft(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
title = d.file.nameWithoutExtension, title = d.file.nameWithoutExtension,
@ -1528,12 +1538,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() _detail.value = d.copy(rev = System.nanoTime())
} }
}.getOrElse { }.getOrElse {
d.busy = null d.busy = null
d.error = it.message ?: "Upload failed." d.error = it.message ?: "Upload failed."
_detail.value = d.copy() _detail.value = d.copy(rev = System.nanoTime())
return@run return@run
} }
d.remoteId = ref.key d.remoteId = ref.key
@ -1541,7 +1551,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() _detail.value = d.copy(rev = System.nanoTime())
// 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
@ -1557,7 +1567,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() _detail.value = d.copy(rev = System.nanoTime())
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
@ -1568,20 +1578,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() _detail.value = d.copy(rev = System.nanoTime())
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() _detail.value = d.copy(rev = System.nanoTime())
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() _detail.value = d.copy(rev = System.nanoTime())
} }
} }
} }
@ -1605,7 +1615,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() _detail.value = d0.copy(rev = System.nanoTime())
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
@ -1614,7 +1624,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() _detail.value = d0.copy(rev = System.nanoTime())
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) {
@ -1628,15 +1638,34 @@ 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() _detail.value = d0.copy(rev = System.nanoTime())
return@launch return@launch
} }
} }
// Poll the target job to a terminal state. // Poll the target job to a terminal state. A job this long
// (10+ min = hundreds of polls) WILL hit a transient fetch
// failure; treating one as terminal (the old `orEmpty()` →
// st == null → break) froze the screen on stale content until
// the user left and returned — exactly the reported symptom.
// So: only a SUCCESSFUL fetch reporting a terminal state
// breaks; failures keep polling up to ~30s of contact loss.
var miss = 0
var gone = 0
while (true) { while (true) {
delay(2000) delay(2000)
val jobs = runCatching { parseJobs(provider.fetchJobs(remoteId)) }.getOrNull() val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) }
.orEmpty() if (fetched.isFailure) {
if (++miss >= 15) {
d0.error = "Lost contact with the engine while " +
"processing — reopen this recording to check."
d0.busy = null
_detail.value = d0.copy(rev = System.nanoTime())
return@launch
}
continue
}
miss = 0
val jobs = fetched.getOrDefault(emptyList())
d0.jobs = jobs 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
@ -1644,9 +1673,12 @@ 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() _detail.value = d0.copy(rev = System.nanoTime())
val st = jobs.firstOrNull { it.jobType == job }?.status val st = jobs.firstOrNull { it.jobType == job }?.status
if (st == null || st in TERMINAL) break if (st in TERMINAL) break
// Job row missing from a healthy fetch: give it a few
// cycles (the engine rewrites rows mid-requeue) then stop.
if (st == null && ++gone >= 3) break
} }
setLive(d0.file.name, null) setLive(d0.file.name, null)
rescanStatuses() rescanStatuses()
@ -1654,7 +1686,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() _detail.value = d0.copy(rev = System.nanoTime())
return@launch return@launch
} }
d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) } d0.transcript = runCatching { parseTranscript(provider.fetchTranscript(remoteId)) }
@ -1662,7 +1694,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) } d0.summary = runCatching { parseSummary(provider.fetchSummary(remoteId)) }
.getOrNull() .getOrNull()
d0.busy = null d0.busy = null
_detail.value = d0.copy() _detail.value = d0.copy(rev = System.nanoTime())
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)
@ -1688,7 +1720,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() _detail.value = d.copy(rev = System.nanoTime())
return return
} }
d.remoteId = remoteId d.remoteId = remoteId
@ -1708,7 +1740,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() _detail.value = d.copy(rev = System.nanoTime())
return return
} }
d.remoteId = remoteId d.remoteId = remoteId
@ -1716,7 +1748,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() _detail.value = d.copy(rev = System.nanoTime())
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 ->
@ -1725,13 +1757,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() if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime())
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() if (_detail.value?.file == d.file) _detail.value = d.copy(rev = System.nanoTime())
} }
} }
} }
@ -1744,18 +1776,41 @@ 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))
} }
/** Write the .odt export next to the audio (same base name as the /** Export summary + transcript into [dir] in "md" or "odt" format.
* .md report, .report.odt extension). Returns null on success or a * Returns null on success (caller shows the path) or a human
* human reason it failed. */ * reason it failed. Never touches the library's own report file. */
fun exportOdt(d: DetailUi): String? { fun exportTo(d: DetailUi, dir: File, format: String): String? {
val out = odtFile(d.file) if (!dir.isDirectory) return "That folder no longer exists."
return runCatching { val base = d.file.nameWithoutExtension
out.writeBytes(renderOdt(d.file.nameWithoutExtension, d.transcript, d.summary)) 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( }.fold(
onSuccess = { null }, onSuccess = { null },
onFailure = { "Couldn't save ${out.name}: ${it.message}" }, onFailure = { "Couldn't save ${out.name}: ${it.message}" },
) )
} }
}
else -> "Unknown export format."
}
}
companion object { companion object {
/** Live pipeline activity for a recording: human label plus the /** Live pipeline activity for a recording: human label plus the
@ -1786,6 +1841,20 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} }
} }
/** Parse server naive-UTC ISO ("…T15:50:12.556516", sometimes Z)
* to epoch millis; null when absent/unparseable. */
fun parseIsoUtc(s: String?): Long? = runCatching {
s ?: return null
if (s.endsWith("Z")) java.time.Instant.parse(s).toEpochMilli()
else java.time.LocalDateTime.parse(s)
.toInstant(java.time.ZoneOffset.UTC).toEpochMilli()
}.getOrNull()
/** "1:23" mm:ss (or h:mm:ss) for elapsed/ETA display. */
fun fmtMMSS(sec: Long): String =
if (sec < 3600) "${sec / 60}:${"%02d".format(sec % 60)}"
else "${sec / 3600}:${"%02d".format(sec % 3600 / 60)}:${"%02d".format(sec % 60)}"
/** " · 1:23" of wait time since a job started running, "" when /** " · 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
@ -1807,6 +1876,134 @@ 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"
@ -1857,9 +2054,6 @@ 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")
} }
} }
@ -1873,3 +2067,27 @@ 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,11 +9,8 @@ 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
@ -155,41 +152,39 @@ 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 ->
Card( SonarCard(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer),
) { ) {
Row(Modifier.fillMaxWidth() Row(Modifier.fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = Shonar.CardPadding),
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))
TextButton({ state.viewSummaryReady(t) }) { ShonarGhostButton({ state.viewSummaryReady(t) }) {
Text("View") Text("View")
} }
TextButton({ state.dismissSummaryToast() }) { ShonarGhostButton({ state.dismissSummaryToast() }) {
Text("✕") Text("✕")
} }
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(Shonar.Space2))
} }
undoToast?.let { t -> undoToast?.let { t ->
Card(Modifier.fillMaxWidth()) { SonarCard(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth() Row(Modifier.fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = Shonar.CardPadding),
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))
TextButton({ state.undoLastTrash() }) { ShonarGhostButton({ state.undoLastTrash() }) {
Text("Undo") Text("Undo")
} }
} }
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(Shonar.Space2))
} }
when (screen) { when (screen) {
DesktopState.Screen.ENGINE -> EngineScreen(state) DesktopState.Screen.ENGINE -> EngineScreen(state)

View file

@ -1,6 +1,5 @@
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
@ -25,21 +24,16 @@ 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.material3.TextButton import androidx.compose.runtime.Composable
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
@ -82,9 +76,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(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Button({ state.startEngine() }) { Text("Start engine") } ShonarButton({ state.startEngine() }) { Text("Start engine") }
OutlinedButton({ state.ensureReady() }) { Text("Retry") } ShonarOutlinedButton({ state.ensureReady() }) { Text("Retry") }
} }
} }
} }
@ -141,11 +135,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)) {
TextButton({ current = home }) { Text("Home") } ShonarGhostButton({ 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)
TextButton({ current = d }) { ShonarGhostButton({ current = d }) {
Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1) Text(d.name.ifEmpty { d.absolutePath }, maxLines = 1)
} }
} }
@ -155,7 +149,7 @@ fun FolderPickerDialog(
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
HorizontalDivider() HorizontalDivider()
if (current.parentFile != null) { if (current.parentFile != null) {
TextButton( ShonarGhostButton(
{ current.parentFile?.let { current = it } }, { current.parentFile?.let { current = it } },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { Text("⋱ .. (go up)") } ) { Text("⋱ .. (go up)") }
@ -167,31 +161,31 @@ fun FolderPickerDialog(
} }
LazyColumn(Modifier.weight(1f)) { LazyColumn(Modifier.weight(1f)) {
items(subs, key = { it.absolutePath }) { d -> items(subs, key = { it.absolutePath }) { d ->
Card(Modifier.fillMaxWidth().clickable { current = d }) { SonarCard(Modifier.fillMaxWidth(),
Row(Modifier.fillMaxWidth().padding(horizontal = 12.dp, onClick = { current = d }) {
vertical = 8.dp), Row(Modifier.fillMaxWidth().padding(horizontal = Shonar.CardPadding,
vertical = Shonar.Space2),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
Text("▸", color = MaterialTheme.colorScheme.primary) Text("▸", color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(Shonar.Space2))
Text(d.name, style = MaterialTheme.typography.bodyMedium) Text(d.name, style = MaterialTheme.typography.bodyMedium)
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(Shonar.Space1))
} }
} }
error?.let { error?.let {
Text(it, color = MaterialTheme.colorScheme.error, HelperText(it, error = true)
style = MaterialTheme.typography.bodySmall)
} }
} }
}, },
confirmButton = { confirmButton = {
TextButton({ ShonarGhostButton({
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 = { TextButton({ onResult(null) }) { Text("Cancel") } }, dismissButton = { ShonarGhostButton({ onResult(null) }) { Text("Cancel") } },
) )
} }
@ -552,6 +546,83 @@ fun LibraryScreen(state: DesktopState) {
} }
} }
// ---- export dialog -----------------------------------------------------------
/**
* Export summary + transcript to a folder of the user's choosing, in
* Markdown or ODT. Reuses the in-app FolderPicker for navigation, so
* there's no native-dialog/zoom weirdness. onDone receives the success
* note; nothing is written until Save.
*/
@androidx.compose.runtime.Composable
fun ExportDialog(
detail: DesktopState.DetailUi,
state: DesktopState,
onDone: (String) -> Unit,
onCancel: () -> Unit,
) {
var dir by remember {
mutableStateOf(state.folder.value ?: File(System.getProperty("user.home")))
}
var format by remember { mutableStateOf("md") }
var picking by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<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
@ -586,6 +657,7 @@ 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()
@ -611,6 +683,11 @@ 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
} }
@ -619,29 +696,28 @@ 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) {
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } ShonarGhostButton({ 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.
TextButton( ShonarGhostButton(
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") }
TextButton( ShonarGhostButton(
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 →") }
TextButton( ShonarDangerButton(
{ confirmDelete = true }, { confirmDelete = true },
enabled = detail.busy == null, enabled = detail.busy == null,
content = { ) {
Text("Delete", color = MaterialTheme.colorScheme.error) Text("Delete")
}, }
)
} }
Column(Modifier.fillMaxWidth().weight(1f) Column(Modifier.fillMaxWidth().weight(1f)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
@ -657,10 +733,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).
Button({ state.stopPlayback() }) { ShonarButton({ state.stopPlayback() }) {
Icon(Icons.Filled.Stop, contentDescription = null, Icon(Icons.Filled.Stop, contentDescription = null,
modifier = Modifier.size(18.dp)) modifier = Modifier.size(Shonar.IconSm))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(Shonar.Space2))
Text("Stop") Text("Stop")
} }
if (transport && ps.durationSec > 0) { if (transport && ps.durationSec > 0) {
@ -679,23 +755,27 @@ fun DetailScreen(state: DesktopState) {
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
} }
} else { } else {
OutlinedButton({ state.playFrom(detail.file) }) { ShonarOutlinedButton({ state.playFrom(detail.file) }) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, Icon(Icons.Filled.PlayArrow, contentDescription = null,
modifier = Modifier.size(18.dp)) modifier = Modifier.size(Shonar.IconSm))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(Shonar.Space2))
Text("Play") Text("Play")
} }
} }
playError?.let { playError?.let {
Text(it, color = MaterialTheme.colorScheme.error, HelperText(it, error = true)
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()) {
OutlinedTextField(recQuery, { recQuery = it }, SonarField(recQuery, { recQuery = it },
label = { Text("Search this recording") }, placeholder = "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.",
@ -707,20 +787,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(2.dp), verticalArrangement = Arrangement.spacedBy(Shonar.Space1),
) { ) {
items(recHits) { hit -> items(recHits) { hit ->
Card(Modifier.fillMaxWidth().clickable { SonarCard(Modifier.fillMaxWidth(),
state.playFrom(detail.file, hit.startSec) onClick = { 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 = MaterialTheme.colorScheme.primary) color = Shonar.Accent)
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(Shonar.Space3))
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))
@ -736,7 +816,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
TextButton({ showModels = !showModels }) { ShonarGhostButton({ showModels = !showModels }) {
Text(if (detail.overrideModel == null) Text(if (detail.overrideModel == null)
"$currentLabel (default)" else currentLabel) "$currentLabel (default)" else currentLabel)
} }
@ -753,76 +833,33 @@ fun DetailScreen(state: DesktopState) {
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
detail.uploadProgress?.let { // Processing pipeline: real stages only. Each row flips when the
LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth()) // engine actually moves; percentages appear only where the backend
Text("Uploading… ${(it * 100).toInt()}%") // measures distance (upload bytes, transcribe audio-time). No
} // decorative motion pretending to be progress.
detail.busy?.takeIf { detail.uploadProgress == null }?.let { val pipeline = DesktopState.pipelineStatus(
Row(verticalAlignment = Alignment.CenterVertically) { uploadProgress = detail.uploadProgress,
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) remoteKnown = state.remoteIdFor(detail.file) != null,
Spacer(Modifier.width(10.dp)) hasTranscript = detail.transcript != null,
Text(it) hasSummary = detail.summary != null,
} 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" }
sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let { if (pipeline != null) {
// Honest milestone progress: the engine counts completed PipelinePanel(pipeline)
// contract units (JSON keys/items finished), so the number } else if (detail.busy != null) {
// means real work done — a determinate bar is truthful now. // Click-landed but no job row yet (POST/token/poll latency):
val toneWord = sJob.tone?.lowercase()?.replace(",", "") // busy is REAL work in flight — showing nothing here was the
?.takeIf { it.isNotBlank() && it != "neutral" } // "hit transcribe, screen looks dead" blind window.
val label = when { SonarCard(modifier = Modifier.fillMaxWidth()) {
it.status == "running" && it.progress != null && it.progress > 0 -> Row(
"Summarizing" + (toneWord?.let { t -> " $t" } ?: "") + verticalAlignment = Alignment.CenterVertically,
"… ${it.progress}%" + horizontalArrangement = Arrangement.spacedBy(Shonar.Space2),
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis()) ) {
it.status == "running" -> "Summarizing… 0% waiting for model" + Text("→", style = MaterialTheme.typography.bodySmall,
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis()) color = MaterialTheme.colorScheme.primary)
else -> "Summary queued" + Text(detail.busy!!, style = MaterialTheme.typography.bodySmall,
DesktopState.waitSuffix(it.createdAt, System.currentTimeMillis())
}
Text(label, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary) 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),
)
} }
} }
} }
@ -852,18 +889,21 @@ 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) {
Button({ state.summarize() }) { Text("Retry summary") } ShonarButton({ state.summarize() }) { Text("Retry summary") }
} }
} }
detail.error?.let { detail.error?.let {
Text(it, color = MaterialTheme.colorScheme.error) HelperText(it, error = true)
if (tJob?.status == "failed") { val tFailed = detail.jobs.firstOrNull { j ->
Button({ state.retry() }) { Text("Retry transcription") } j.jobType == "transcribe" && j.status == "failed"
}
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) {
Button({ state.transcribe() }) { Text("Transcribe") } ShonarButton({ 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
@ -890,14 +930,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(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
TextButton({ ShonarGhostButton({
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()) {
TextButton({ ShonarGhostButton({
clipboard.setText(AnnotatedString( clipboard.setText(AnnotatedString(
actionItems.joinToString("\n") { "- $it" })) actionItems.joinToString("\n") { "- $it" }))
copiedNote = "Action items copied" copiedNote = "Action items copied"
@ -920,27 +960,25 @@ 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(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
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(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
SUMMARIZE_PERSONAS.forEach { (label, tone) -> SUMMARIZE_PERSONAS.forEach { (label, tone) ->
FilterChip( PillChip(
selected = chipTone == tone, selected = chipTone == tone,
enabled = voicesEnabled, enabled = voicesEnabled,
onClick = { onClick = {
sumTone = tone sumTone = tone
state.summarize(tone) state.summarize(tone)
}, },
label = { Text(label) }, label = label,
) )
} }
} }
if (!voicesEnabled && !remoteKnown) { if (!voicesEnabled && !remoteKnown) {
Text("Summarize unlocks once this file is uploaded (press Transcribe).", HelperText("Summarize unlocks once this file is uploaded (press Transcribe).")
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
} }
@ -985,68 +1023,52 @@ 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(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
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(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
SUMMARIZE_PERSONAS.forEach { (label, tone) -> SUMMARIZE_PERSONAS.forEach { (label, tone) ->
FilterChip( PillChip(
selected = chipTone == tone, selected = chipTone == tone,
enabled = voicesEnabled, enabled = voicesEnabled,
onClick = { onClick = {
sumTone = tone sumTone = tone
state.summarize(tone) state.summarize(tone)
}, },
label = { Text(label) }, label = label,
) )
} }
} }
} }
} }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
TextButton({ state.transcribe() }) { ShonarGhostButton({ 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…")
} }
// Summarize progress right where the buttons are: a running }
// summarize must be visible next to the chips that started it. // Live status right where the chips are — one honest line
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } // mirroring the pipeline panel's Summary stage (the panel may
// be scrolled out of view when the chips get clicked). No
// bar animation: the engine reports stage, not distance.
val sRunning = sJob?.status in setOf("queued", "running") val sRunning = sJob?.status in setOf("queued", "running")
if (sRunning) { if (sRunning && pipeline != null) {
val sStatus = sJob?.status val sStage = pipeline.stages.firstOrNull { it.label == "Summary" }
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(when { append("→ ").append(sStage?.note
sStatus == "queued" -> "Queued…" + ?: if (sJob?.status == "queued") "queued on the summarizer"
DesktopState.waitSuffix(sJob?.createdAt, else "writing")
System.currentTimeMillis()) pipeline.elapsedSec?.let { append(" · ${DesktopState.fmtMMSS(it)} elapsed") }
sProg != null && sProg > 0 -> (sJob?.attempt ?: 0).let {
"Summarizing" + (sJob?.tone?.lowercase() if (it > 0) append(" (attempt $it/${sJob?.maxAttempts})")
?.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)
} }
@ -1072,7 +1094,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) {
TextButton({ ShonarGhostButton({
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") }
@ -1094,20 +1116,22 @@ fun DetailScreen(state: DesktopState) {
} }
} }
} else { } else {
OutlinedTextField( SonarField(
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(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
Button( ShonarButton(
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") }
OutlinedButton( ShonarOutlinedButton(
enabled = !savingTranscript, enabled = !savingTranscript,
onClick = { editDraft = null }, onClick = { editDraft = null },
) { Text("Cancel") } ) { Text("Cancel") }
@ -1138,19 +1162,27 @@ fun DetailScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
// Export lives once for both paths (live transcript or saved report). // Export: pick a folder and a format — the summary + transcript
// leave the library as a file you can keep anywhere.
if (detail.transcript != null || detail.reportText != null) { if (detail.transcript != null || detail.reportText != null) {
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
OutlinedButton(onClick = { ShonarOutlinedButton(onClick = { showExport = true }) {
odtNote = state.exportOdt(detail) Text("Export…")
?: "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 = MaterialTheme.colorScheme.onSurfaceVariant) color = Shonar.Accent)
} }
} }
if (showExport) {
ExportDialog(detail = detail, state = state,
onDone = { note ->
showExport = false
odtNote = note
},
onCancel = { showExport = false })
}
} }
// (Voice chips + Re-transcribe + summarize progress live above the // (Voice chips + Re-transcribe + summarize progress live above the
// transcript, right under the summary.) // transcript, right under the summary.)
@ -1182,12 +1214,14 @@ private fun ModelRow(
) { ) {
val selected = detail.overrideModel == value || val selected = detail.overrideModel == value ||
(value == null && detail.overrideModel == null) (value == null && detail.overrideModel == null)
Card( SonarCard(
Modifier.fillMaxWidth().clickable { state.setOverride(value) }, Modifier.fillMaxWidth(),
onClick = { state.setOverride(value) },
) { ) {
Row(Modifier.fillMaxWidth().padding(10.dp), Row(Modifier.fillMaxWidth().padding(Shonar.CardPadding),
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()) {
@ -1215,38 +1249,36 @@ 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) {
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } ShonarGhostButton({ 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)
Text("Recordings folder", style = MaterialTheme.typography.titleMedium) SectionHeader("Recordings folder")
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
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))
OutlinedButton({ showPicker = true }) { ShonarOutlinedButton({ showPicker = true }) {
Text(if (folder == null) "Choose folder…" else "Change folder…") Text(if (folder == null) "Choose folder…" else "Change folder…")
} }
} }
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(Shonar.Space1))
Text("Summarizer", style = MaterialTheme.typography.titleMedium) SectionHeader("Summarizer")
Text("Where summaries are written. Restarting the engine applies " + HelperText("Where summaries are written. Restarting the engine applies " +
"the choice; work already running carries over.", "the choice; work already running carries over.")
style = MaterialTheme.typography.bodySmall, Row(horizontalArrangement = Arrangement.spacedBy(Shonar.Space2)) {
color = MaterialTheme.colorScheme.onSurfaceVariant) PillChip(selected = summarizer == "auto",
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = summarizer == "auto",
onClick = { state.setSummarizer("auto") }, onClick = { state.setSummarizer("auto") },
label = { Text("Auto") }) label = "Auto")
FilterChip(selected = summarizer == "lan", PillChip(selected = summarizer == "lan",
onClick = { state.setSummarizer("lan") }, onClick = { state.setSummarizer("lan") },
label = { Text("LAN server (H200)") }) label = "LAN server (H200)")
FilterChip(selected = summarizer == "local", PillChip(selected = summarizer == "local",
onClick = { state.setSummarizer("local") }, onClick = { state.setSummarizer("local") },
label = { Text("This laptop (Ollama)") }) label = "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 — " +
@ -1261,11 +1293,9 @@ fun SettingsScreen(state: DesktopState) {
}, },
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(Shonar.Space1))
Text("Default transcription model", style = MaterialTheme.typography.titleMedium) SectionHeader("Default transcription model")
Text("Applies to future transcriptions. Each recording keeps the model it used.", HelperText("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))
@ -1279,22 +1309,24 @@ fun SettingsScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.error) color = MaterialTheme.colorScheme.error)
} }
models.models.forEach { m -> models.models.forEach { m ->
Card(Modifier.fillMaxWidth()) { SonarCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp), Column(Modifier.padding(Shonar.CardPadding),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(Shonar.Space1)) {
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.bodyMedium, style = MaterialTheme.typography.titleSmall,
color = if (m.isDefault) Shonar.Accent
else Shonar.TextPrimary,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (!m.isDefault) { if (!m.isDefault) {
TextButton({ state.setDefaultModel(m.name) }) { ShonarGhostButton({ state.setDefaultModel(m.name) }) {
Text("Set default") Text("Set default")
} }
} }
if (!m.downloaded) { if (!m.downloaded) {
TextButton({ state.downloadModel(m.name) }) { ShonarGhostButton({ state.downloadModel(m.name) }) {
Text("Download") Text("Download")
} }
} }
@ -1322,3 +1354,88 @@ fun SettingsScreen(state: DesktopState) {
} }
} }
} }
/** Processing pipeline panel — stage truth from `DesktopState.pipelineStatus`.
*
* Each row carries exactly one state marker (✓ done, → current, ✗ failed,
* ○ not started) and only updates when the engine actually moves that
* stage. A percentage bar appears only when the backend reports real
* distance (upload bytes, transcribe audio-time); stage-only work shows
* a steady marker, never a moving fake bar. ETA appears only when the
* engine's percentage supports one. */
@Composable
fun PipelinePanel(p: PipelineUi) {
SonarCard(modifier = Modifier.fillMaxWidth()) {
// SonarCard's Surface places un-wrapped siblings in a Box — without
// this Column every row stacked on top of the headline (the
// "text overlapping while summarizing" garble).
Column(
modifier = Modifier.fillMaxWidth().padding(Shonar.CardPadding),
verticalArrangement = Arrangement.spacedBy(Shonar.Space1),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.headline, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f, fill = false),
maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
buildString {
p.elapsedSec?.let { append("${DesktopState.fmtMMSS(it)} elapsed") }
p.etaSec?.let {
if (isNotEmpty()) append(" · ")
append("~${DesktopState.fmtMMSS(it)} remaining")
}
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(Shonar.Space2))
p.stages.forEach { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Shonar.Space2),
) {
Text(
when (s.state) {
"done" -> "✓"
"active" -> "→"
"failed" -> "✗"
else -> "○"
},
style = MaterialTheme.typography.bodySmall,
color = when (s.state) {
"failed" -> MaterialTheme.colorScheme.error
"pending" -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.primary
},
)
Text(
s.label + (s.pct?.let { " $it%" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = if (s.state == "pending")
MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface,
)
s.note?.let {
Text("· $it", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
s.pct?.takeIf { s.state == "active" }?.let {
LinearProgressIndicator(
progress = { it / 100f },
modifier = Modifier
.padding(start = Shonar.Space4)
.fillMaxWidth()
.height(3.dp),
)
}
}
}
}
}

View file

@ -107,4 +107,124 @@ 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,11 +138,18 @@ class ReportsTest {
assertTrue("No transcript available." in xml) assertTrue("No transcript available." in xml)
} }
@Test fun odtFileName() { @Test fun odtExportFilenamesThroughExportTo() {
val dir = Files.createTempDirectory("odt").toFile() // exportTo is the only export path now; check both formats land
// with the promised names in the chosen folder.
val dir = Files.createTempDirectory("exp").toFile()
try { try {
val audio = File(dir, "chat.m4a") val audio = File(dir, "chat.m4a")
assertEquals("chat.report.odt", DesktopState.odtFile(audio).name) val state = DesktopState(Files.createTempDirectory("expstate").toFile())
val detail = DesktopState.DetailUi(audio)
// No data yet -> honest refusal, no file written.
assertTrue(state.exportTo(detail, dir, "odt") != null)
assertTrue(state.exportTo(detail, dir, "md") != null)
assertTrue(File(dir, "chat.report.odt").exists() == false)
} finally { } finally {
dir.deleteRecursively() dir.deleteRecursively()
} }

View file

@ -86,7 +86,13 @@ async def create_upload(body: UploadSessionCreate, user: CurrentUser, session: S
session, user.id, body.declared_mime_type, body.declared_size_bytes, 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,6 +435,21 @@ 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", ""),
@ -549,6 +564,25 @@ 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,6 +251,45 @@ 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 -----------------------------------------------------------------