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).
This commit is contained in:
avi 2026-09-18 19:45:58 -05:00
commit 1a6acd92d6
3 changed files with 340 additions and 108 deletions

View file

@ -1809,6 +1809,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
@ -1830,6 +1844,119 @@ 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)
}
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"
@ -1893,3 +2020,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

@ -33,6 +33,7 @@ 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.Text import androidx.compose.material3.Text
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
@ -827,78 +828,20 @@ 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
// means real work done — a determinate bar is truthful now.
val toneWord = sJob.tone?.lowercase()?.replace(",", "")
?.takeIf { it.isNotBlank() && it != "neutral" }
val label = when {
it.status == "running" && it.progress != null && it.progress > 0 ->
"Summarizing" + (toneWord?.let { t -> " $t" } ?: "") +
"… ${it.progress}%" +
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
it.status == "running" -> "Summarizing… 0% waiting for model" +
DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis())
else -> "Summary queued" +
DesktopState.waitSuffix(it.createdAt, System.currentTimeMillis())
}
Text(label, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary)
if (it.status == "running") {
Spacer(Modifier.height(4.dp))
if (it.progress != null && it.progress > 0) {
LinearProgressIndicator(
progress = { it.progress / 100f },
modifier = Modifier.fillMaxWidth().height(4.dp),
)
} else {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth().height(4.dp),
)
}
}
} }
// Rescue-swap note: the engine succeeded but had to switch // Rescue-swap note: the engine succeeded but had to switch
// summarizers mid-job. Tell the user in plain words, not the raw // summarizers mid-job. Tell the user in plain words, not the raw
@ -931,7 +874,10 @@ fun DetailScreen(state: DesktopState) {
} }
detail.error?.let { detail.error?.let {
HelperText(it, error = true) HelperText(it, error = true)
if (tJob?.status == "failed") { val tFailed = detail.jobs.firstOrNull { j ->
j.jobType == "transcribe" && j.status == "failed"
}
if (tFailed != null) {
ShonarButton({ state.retry() }) { Text("Retry transcription") } ShonarButton({ state.retry() }) { Text("Retry transcription") }
} }
} }
@ -1088,43 +1034,21 @@ fun DetailScreen(state: DesktopState) {
Text("Export…") Text("Export…")
} }
} }
// Summarize progress right where the buttons are: a running // Live status right where the chips are — one honest line
// summarize must be visible next to the chips that started it. // mirroring the pipeline panel's Summary stage (the panel may
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } // 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)
} }
@ -1410,3 +1334,78 @@ 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()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(p.headline, style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary)
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,86 @@ 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))
}
} }