diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index f39abf2..d966881 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -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 * under 5s or the start time is unknown. The llama-swap backend * 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. */ fun jobLabel(jobs: List): String? = jobProgress(jobs)?.label + /** Linear ETA from elapsed + real pct; null unless the sample is + * meaningful (>=15% done leaves the noisy startup zone, <=95% + * avoids promising the last think-token). */ + fun etaFrom(elapsedSec: Long, pct: Int?): Long? { + if (pct == null || pct < 15 || pct > 95) return null + return elapsedSec * (100 - pct) / pct + } + + /** Build the pipeline view from live job rows + upload fraction. + * Null when nothing is happening (detail screen shows no panel). */ + fun pipelineStatus( + uploadProgress: Float?, + remoteKnown: Boolean, + hasTranscript: Boolean, + hasSummary: Boolean, + jobs: List, + nowMs: Long = System.currentTimeMillis(), + ): PipelineUi? { + val t = jobs.firstOrNull { it.jobType == "transcribe" } + val s = jobs.firstOrNull { it.jobType == "summarize" } + val uploading = uploadProgress != null + val tRunning = t?.status == "running" + val sLive = s?.status in setOf("queued", "running") + if (!uploading && !tRunning && !sLive && + !(t?.status == "succeeded" && !hasTranscript) && + !(s?.status == "succeeded" && !hasSummary)) return null + + fun elapsedOf(j: JobInfo?): Long? { + val start = parseIsoUtc(j?.startedAt ?: j?.createdAt) ?: return null + return ((nowMs - start) / 1000).coerceAtLeast(0) + } + + // Upload: real bytes fraction while pushing; done once the + // engine has the file (a transcribe job exists). + val uploadStage = PipelineStage( + "Upload", + when { + uploading -> "active" + t != null || hasTranscript -> "done" + else -> "pending" + }, + pct = uploadProgress?.let { (it * 100).toInt() }, + ) + // Transcribe: stage from the engine; real pct while running. + val tStage = PipelineStage( + "Transcription", + when { + t?.status == "failed" -> "failed" + tRunning -> "active" + t?.status == "succeeded" || hasTranscript -> "done" + else -> "pending" + }, + pct = t?.progress?.takeIf { tRunning }, + note = when { + tRunning && t?.stage == "loading-model" -> "loading model…" + tRunning && t?.progress == null -> "working…" + else -> null + }, + ) + // Summarize: engine reports streaming, never distance — so no + // pct, no ETA; the stage + elapsed are the honest signal. + val sStage = PipelineStage( + "Summary", + when { + s?.status == "failed" -> "failed" + s?.status == "running" -> "active" + s?.status == "queued" -> "active" + s?.status == "succeeded" || hasSummary -> "done" + else -> "pending" + }, + note = when { + s?.status == "queued" -> "queued on the summarizer" + s?.status == "running" && + (s.progress ?: 0) > 0 -> { + val tone = s.tone?.lowercase()?.replace(",", "") + ?.takeIf { it.isNotBlank() && it != "neutral" } + "writing${tone?.let { " in a $it voice" } ?: ""} · streaming reply" + } + s?.status == "running" -> "waiting for model" + else -> null + }, + ) + // Finalizing: engine says done but the app hasn't landed the + // fresh transcript/summary locally yet. + val finalizing = (t?.status == "succeeded" && !hasTranscript) || + (s?.status == "succeeded" && !hasSummary) + val fStage = PipelineStage( + "Finalizing", + when { + finalizing -> "active" + hasTranscript && hasSummary -> "done" + else -> "pending" + }, + ) + + val headline = when { + uploading -> "Uploading recording…" + tRunning -> "Transcribing recording…" + s?.status == "queued" -> "Queued: summary pending…" + s?.status == "running" -> "Summarizing recording…" + else -> "Finalizing…" + } + val activeJob = when { + tRunning -> t + sLive -> s + else -> null + } + val elapsed = elapsedOf(activeJob) + val eta = if (tRunning) etaFrom(elapsed ?: 0, t?.progress) else null + return PipelineUi(headline, + listOf(uploadStage, tStage, sStage, fStage), elapsed, eta) + } + const val KEY_URL = "server.url" const val KEY_FOLDER = "library.folder" const val KEY_PASSWORD = "local.password" @@ -1892,4 +2019,28 @@ data class SearchHit( ) /** What the pipeline is doing for a library file right now. */ -data class LiveProgress(val label: String, val fraction: Float?) \ No newline at end of file +data class LiveProgress(val label: String, val fraction: Float?) + +/** One row of the processing pipeline for the detail screen. + * States only change when real work moves: nothing here is animated + * by a timer pretending to know more than the engine. */ +data class PipelineStage( + val label: String, + /** "done" ✓ · "active" → · "pending" ○ · "failed" ✗ */ + val state: String, + /** Real 0-100 percentage when the backend reports one that means + * distance (upload bytes, transcribe audio-time); null when only + * the stage is known. */ + val pct: Int? = null, + /** Extra clause for the active stage ("loading model…"). */ + val note: String? = null, +) + +data class PipelineUi( + val headline: String, + val stages: List, + val elapsedSec: Long?, + /** Honest ETA: only when derived from a real percentage that measures + * distance (upload/transcribe). Never for summarize. */ + val etaSec: Long?, +) \ No newline at end of file diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 564c2ea..e35d0ed 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect @@ -827,78 +828,20 @@ fun DetailScreen(state: DesktopState) { } Spacer(Modifier.height(4.dp)) } - detail.uploadProgress?.let { - LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth()) - Text("Uploading… ${(it * 100).toInt()}%") - } - detail.busy?.takeIf { detail.uploadProgress == null }?.let { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(Modifier.width(20.dp).height(20.dp)) - Spacer(Modifier.width(10.dp)) - Text(it) - } - } - val tJob = detail.jobs.firstOrNull { it.jobType == "transcribe" } - tJob?.let { - val running = it.status == "running" - val label = when { - it.status == "succeeded" -> "Transcribed" - it.status == "failed" -> "Failed" - it.stage == "loading-model" -> "Loading model…" - running && it.progress != null -> "Transcribing… ${it.progress}%" - it.stage == "transcribing" -> "Transcribing…" - running -> "Working… (attempt ${it.attempt}/${it.maxAttempts})" - else -> "Queued" - } - Text(label, style = MaterialTheme.typography.bodyMedium, - color = if (it.status == "failed") MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.primary) - if (running) { - Spacer(Modifier.height(4.dp)) - if (it.progress != null) { - LinearProgressIndicator( - progress = { it.progress / 100f }, - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } - } - } + // Processing pipeline: real stages only. Each row flips when the + // engine actually moves; percentages appear only where the backend + // measures distance (upload bytes, transcribe audio-time). No + // decorative motion pretending to be progress. + val pipeline = DesktopState.pipelineStatus( + uploadProgress = detail.uploadProgress, + remoteKnown = state.remoteIdFor(detail.file) != null, + hasTranscript = detail.transcript != null, + hasSummary = detail.summary != null, + jobs = detail.jobs, + ) val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } - sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let { - // Honest milestone progress: the engine counts completed - // contract units (JSON keys/items finished), so the number - // means real work done — a determinate bar is truthful now. - val toneWord = sJob.tone?.lowercase()?.replace(",", "") - ?.takeIf { it.isNotBlank() && it != "neutral" } - val label = when { - it.status == "running" && it.progress != null && it.progress > 0 -> - "Summarizing" + (toneWord?.let { t -> " $t" } ?: "") + - "… ${it.progress}%" + - DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis()) - it.status == "running" -> "Summarizing… 0% waiting for model" + - DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis()) - else -> "Summary queued" + - DesktopState.waitSuffix(it.createdAt, System.currentTimeMillis()) - } - Text(label, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary) - if (it.status == "running") { - Spacer(Modifier.height(4.dp)) - if (it.progress != null && it.progress > 0) { - LinearProgressIndicator( - progress = { it.progress / 100f }, - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } else { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth().height(4.dp), - ) - } - } + if (pipeline != null) { + PipelinePanel(pipeline) } // Rescue-swap note: the engine succeeded but had to switch // summarizers mid-job. Tell the user in plain words, not the raw @@ -931,7 +874,10 @@ fun DetailScreen(state: DesktopState) { } detail.error?.let { 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") } } } @@ -1088,43 +1034,21 @@ fun DetailScreen(state: DesktopState) { Text("Export…") } } - // Summarize progress right where the buttons are: a running - // summarize must be visible next to the chips that started it. - val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } + // Live status right where the chips are — one honest line + // mirroring the pipeline panel's Summary stage (the panel may + // be scrolled out of view when the chips get clicked). No + // bar animation: the engine reports stage, not distance. val sRunning = sJob?.status in setOf("queued", "running") - if (sRunning) { - val sStatus = sJob?.status - val sProg = sJob?.progress - val sAttempt = sJob?.attempt ?: 0 - // Determinate bar once real units complete: the engine - // counts finished JSON contract units, so the percent - // means work done, not vibes. Indeterminate only while - // queued or pre-first-token. - if (sProg != null && sProg > 0) { - LinearProgressIndicator(progress = { sProg / 100f }, - modifier = Modifier.fillMaxWidth()) - } else { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - } + if (sRunning && pipeline != null) { + val sStage = pipeline.stages.firstOrNull { it.label == "Summary" } Text(buildString { - append(when { - sStatus == "queued" -> "Queued…" + - DesktopState.waitSuffix(sJob?.createdAt, - System.currentTimeMillis()) - sProg != null && sProg > 0 -> - "Summarizing" + (sJob?.tone?.lowercase() - ?.replace(",", "") - ?.takeIf { it.isNotBlank() && it != "neutral" } - ?.let { " $it" } ?: "") + - "… $sProg%" + - DesktopState.waitSuffix(sJob?.startedAt, - System.currentTimeMillis()) - else -> "Summarizing… 0% waiting for model" + - DesktopState.waitSuffix(sJob?.startedAt, - System.currentTimeMillis()) - }) - if (sAttempt > 0) - append(" (attempt $sAttempt/${sJob?.maxAttempts})") + append("→ ").append(sStage?.note + ?: if (sJob?.status == "queued") "queued on the summarizer" + else "writing") + pipeline.elapsedSec?.let { append(" · ${DesktopState.fmtMMSS(it)} elapsed") } + (sJob?.attempt ?: 0).let { + if (it > 0) append(" (attempt $it/${sJob?.maxAttempts})") + } }, style = MaterialTheme.typography.bodySmall, 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), + ) + } + } + } +} diff --git a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt index c78754f..c054e54 100644 --- a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt @@ -107,4 +107,86 @@ class JobProgressTest { // visible time too, so it must count from created_at. assertEquals(" · 2:04", DesktopState.waitSuffix(iso, t0)) } + + // ---- pipelineStatus: honest stage rows, no fake distance ---- + + private fun pjob(type: String, status: String, stage: String? = null, + prog: Int? = null, started: String? = null) = + JobInfo(jobType = type, status = status, attempt = 0, maxAttempts = 3, + stage = stage, progress = prog, startedAt = started) + + @Test fun `pipeline is null when idle`() { + assertNull(DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = true, hasSummary = true, jobs = emptyList())) + } + + @Test fun `summarize running shows stage not distance`() { + // The engine's summarize percent means streaming, not distance — + // so the pipeline must NOT surface a pct or ETA for it. + val now = System.currentTimeMillis() + val started = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(now - 42_000), java.time.ZoneOffset.UTC) + .withNano(0).toString() + val p = DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = true, hasSummary = false, + jobs = listOf(pjob("transcribe", "succeeded"), + pjob("summarize", "running", prog = 99, started = started)), + nowMs = now)!! + assertEquals("Summarizing recording…", p.headline) + assertNull(p.etaSec) + assertEquals(42L, p.elapsedSec) + val s = p.stages.first { it.label == "Summary" } + assertEquals("active", s.state) + assertNull(s.pct) + assertEquals(0, p.stages.count { it.state == "failed" }) + assertEquals("done", p.stages.first { it.label == "Transcription" }.state) + } + + @Test fun `transcribe running carries real pct and eta`() { + val now = System.currentTimeMillis() + val started = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(now - 60_000), java.time.ZoneOffset.UTC) + .withNano(0).toString() + val p = DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = false, hasSummary = false, + jobs = listOf(pjob("transcribe", "running", stage = "transcribing", + prog = 40, started = started)), + nowMs = now)!! + assertEquals("Transcribing recording…", p.headline) + val t = p.stages.first { it.label == "Transcription" } + assertEquals("active", t.state) + assertEquals(40, t.pct) + // 60s elapsed at 40% -> 90s remaining, both real-derived. + assertEquals(60L, p.elapsedSec) + assertEquals(90L, p.etaSec) + } + + @Test fun `upload fraction is a real pct on the Upload row`() { + val p = DesktopState.pipelineStatus( + uploadProgress = 0.66f, remoteKnown = false, + hasTranscript = false, hasSummary = false, jobs = emptyList())!! + assertEquals("Uploading recording…", p.headline) + val u = p.stages.first { it.label == "Upload" } + assertEquals("active", u.state) + assertEquals(66, u.pct) + } + + @Test fun `engine done but app not landed shows Finalizing active`() { + val p = DesktopState.pipelineStatus( + uploadProgress = null, remoteKnown = true, + hasTranscript = true, hasSummary = false, + jobs = listOf(pjob("transcribe", "succeeded"), + pjob("summarize", "succeeded")))!! + assertEquals("Finalizing…", p.headline) + assertEquals("active", p.stages.first { it.label == "Finalizing" }.state) + } + + @Test fun `eta withheld in the noisy edges of a run`() { + assertNull(DesktopState.etaFrom(10, 5)) // too early to trust + assertNull(DesktopState.etaFrom(300, 99)) // no promising the last token + assertEquals(25L, DesktopState.etaFrom(100, 80)) + } }