diff --git a/STATUS.md b/STATUS.md index c9c1a60..cd64dfc 100644 --- a/STATUS.md +++ b/STATUS.md @@ -3,6 +3,28 @@ Checkpoint for the standalone desktop app. Prepend a new dated section per material change; keep history below. +## 2026-09-15 (evening) — summarize progress bar, job adoption + +**Committed:** `7fa99e8` streaming summarize progress, `ba54cb8` job adoption. + +- Summarize now shows a real determinate bar + `Summarizing… N%` (same look + as the main screen). Backend: both LLM adapters stream (SSE / NDJSON) and + report 0–99% as tokens arrive; 100 only on success. No fake movement — + before the first token the label reads "Summarizing… waiting for model" + (cold LLM / server queueing was indistinguishable from a dead app). +- Adoption of engine jobs across app restarts: startup scan no longer skips + files that have a report (re-summarize was orphaned), a reprocess that + hits 409 "already running" attaches to the running job instead of + erroring, and live work outranks a saved report in library status. +- Verified live: screencap 19:41 — library row shows the bar + waiting + label mid re-summarize; job settled to succeeded 100 with the % racing + 48→99→100 once tokens flowed (H200 queueing caused the earlier "nothing + happens"). pytest 79 passed/1 skipped · ruff clean · gradle green. +- Note: the dev loop died once (~19:35, cause unclear — gradle `run` exit + 143 chain); relaunched via background `./desktop-dev.sh`. + +**Done earlier (Sep 15 morning):** see below. + ## 2026-09-15 — standalone desktop, Android fully removed **Verified state (live this session):** diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 058c099..32bf2c4 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -39,6 +39,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -539,6 +540,15 @@ fun DetailScreen(state: DesktopState) { var recQuery by remember { mutableStateOf("") } // Voice for the next summarize: null = Neutral (server default). var sumTone by remember(detail.file.name) { mutableStateOf(null) } + // Transcript edit mode: draft holds the working text (null = viewing). + // Keyed on file AND transcript version: switching recordings or a + // successful save (version bump) drops the draft back to viewing. + var editDraft by remember(detail.file.name, detail.transcript?.version) { + mutableStateOf(null) + } + var savingTranscript by remember(detail.file.name) { mutableStateOf(false) } + // The save (or its failure) clears busy — release the button lock. + LaunchedEffect(detail.busy) { if (detail.busy == null) savingTranscript = false } val playingFile by state.playingFile.collectAsState() val playError by state.playError.collectAsState() val isPlaying = playingFile?.absolutePath == detail.file.absolutePath @@ -697,7 +707,8 @@ fun DetailScreen(state: DesktopState) { val label = when { it.status == "running" && it.progress != null -> "Summarizing… ${it.progress}%" - it.status == "running" -> "Summarizing… waiting for model" + it.status == "running" -> "Summarizing… waiting for model" + + DesktopState.waitSuffix(it.startedAt, System.currentTimeMillis()) else -> "Summary queued" } Text(label, style = MaterialTheme.typography.bodyMedium, @@ -727,22 +738,66 @@ fun DetailScreen(state: DesktopState) { Button({ state.transcribe() }) { Text("Transcribe") } } detail.transcript?.let { t -> - Text("Transcript", style = MaterialTheme.typography.titleMedium) - LazyColumn(Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (t.segments.isNotEmpty()) { - items(t.segments.size) { i -> - val s = t.segments[i] - Text("[${fmtTs(s.startSec)}] ${s.text}", - style = MaterialTheme.typography.bodyMedium) - } - } else { - item { Text(t.text) } + val remoteKnownForEdit = state.remoteIdFor(detail.file) != null + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Transcript", style = MaterialTheme.typography.titleMedium) + if (t.editedByUser) { + Spacer(Modifier.width(8.dp)) + Text("· edited by you", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.weight(1f)) + if (editDraft == null && remoteKnownForEdit && detail.busy == null) { + TextButton({ + editDraft = if (t.segments.isNotEmpty()) + t.segments.joinToString("\n") { it.text } else t.text + }) { Text("Edit") } } } - Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) + val draft = editDraft + if (draft == null) { + LazyColumn(Modifier.fillMaxWidth().weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (t.segments.isNotEmpty()) { + items(t.segments.size) { i -> + val s = t.segments[i] + Text("[${fmtTs(s.startSec)}] ${s.text}", + style = MaterialTheme.typography.bodyMedium) + } + } else { + item { Text(t.text) } + } + } + } else { + OutlinedTextField( + value = draft, + onValueChange = { editDraft = it }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = draft.isNotBlank() && !savingTranscript, + onClick = { + savingTranscript = true + state.saveTranscriptEdit(draft) + }, + ) { Text(if (savingTranscript) "Saving…" else "Save") } + OutlinedButton( + enabled = !savingTranscript, + onClick = { editDraft = null }, + ) { Text("Cancel") } + } + Text("Your corrected words are saved as the official version — " + + "re-transcribing later will not overwrite them.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (editDraft == null) { + Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } } detail.summary?.let { s -> if (s.short.isNotBlank()) { @@ -833,7 +888,9 @@ fun DetailScreen(state: DesktopState) { append(when { sStatus == "queued" -> "Queued…" sProg != null && sProg > 0 -> "Summarizing… $sProg%" - else -> "Summarizing… waiting for model" + else -> "Summarizing… waiting for model" + + DesktopState.waitSuffix(sJob?.startedAt, + System.currentTimeMillis()) }) sumTone?.let { append(" · $it") } if (sAttempt > 0) @@ -917,6 +974,7 @@ fun SettingsScreen(state: DesktopState) { val modelsError = state.modelsError.collectAsState().value val url by state.serverUrl.collectAsState() val folder by state.folder.collectAsState() + val summarizer by state.summarizer.collectAsState() var showPicker by remember { mutableStateOf(false) } // Root is scrollable: in split-screen/short windows the model cards @@ -941,6 +999,33 @@ fun SettingsScreen(state: DesktopState) { } } Spacer(Modifier.height(4.dp)) + Text("Summarizer", style = MaterialTheme.typography.titleMedium) + Text("Where summaries are written. Restarting the engine applies " + + "the choice; work already running carries over.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = summarizer == "auto", + onClick = { state.setSummarizer("auto") }, + label = { Text("Auto") }) + FilterChip(selected = summarizer == "lan", + onClick = { state.setSummarizer("lan") }, + label = { Text("LAN server (H200)") }) + FilterChip(selected = summarizer == "local", + onClick = { state.setSummarizer("local") }, + label = { Text("This laptop (Ollama)") }) + } + Text(when (summarizer) { + "lan" -> "Best quality; shares the H200 with other clients — " + + "summaries may wait in its queue before starting." + "local" -> "Private and never queued behind other users; " + + "smaller model, so summaries are plainer." + else -> "Uses the LAN server when its key is available, " + + "otherwise this laptop's Ollama." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(4.dp)) Text("Default transcription model", style = MaterialTheme.typography.titleMedium) Text("Applies to future transcriptions. Each recording keeps the model it used.", style = MaterialTheme.typography.bodySmall, diff --git a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt index 117fdb7..a79a87d 100644 --- a/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt @@ -7,8 +7,10 @@ import org.junit.Test class JobProgressTest { - private fun job(type: String, status: String, stage: String? = null, progress: Int? = null) = - JobInfo(jobType = type, status = status, stage = stage, progress = progress) + private fun job(type: String, status: String, stage: String? = null, progress: Int? = null, + startedIso: String? = null) = + JobInfo(jobType = type, status = status, stage = stage, progress = progress, + startedAt = startedIso) @Test fun `idle when nothing running`() { assertNull(DesktopState.jobProgress(emptyList())) @@ -55,4 +57,28 @@ class JobProgressTest { assertEquals("summarizing… 37%", p?.label) assertEquals(0.37f, p!!.fraction!!, 0.0001f) } + + @Test fun `waiting label carries elapsed time`() { + // Second-exact base: LocalDateTime.toString drops trailing zeros, + // so fractional drift would round the label to 1:22. + val t0 = System.currentTimeMillis() / 1000 * 1000 + val iso = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(t0 - 83_000), java.time.ZoneOffset.UTC) + .withNano(0).toString() + val p = DesktopState.jobProgress( + listOf(job("summarize", "running", startedIso = iso)), nowMs = t0) + assertEquals("summarizing… waiting for model · 1:23", p?.label) + } + + @Test fun `waitSuffix formats and ignores unknowns`() { + val t = java.time.LocalDateTime.of(2026, 9, 16, 15, 0, 0) + val now = t.toInstant(java.time.ZoneOffset.UTC).toEpochMilli() + assertEquals("", DesktopState.waitSuffix(null, now)) + assertEquals("", DesktopState.waitSuffix("garbage", now)) + assertEquals("", DesktopState.waitSuffix(t.minusSeconds(3).toString(), now)) + assertEquals(" · 42s", DesktopState.waitSuffix(t.minusSeconds(42).toString(), now)) + assertEquals(" · 5:01", DesktopState.waitSuffix(t.minusSeconds(301).toString(), now)) + assertEquals(" · 1:02", DesktopState.waitSuffix( + t.minusSeconds(62).toString() + "Z", now)) + } } diff --git a/shared/com/shonar/recording/AiContent.kt b/shared/com/shonar/recording/AiContent.kt index 0e92cea..84b99fe 100644 --- a/shared/com/shonar/recording/AiContent.kt +++ b/shared/com/shonar/recording/AiContent.kt @@ -57,6 +57,8 @@ data class JobInfo( /** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */ val stage: String? = null, val progress: Int? = null, + /** ISO-8601 UTC when the job started running (null if unknown). */ + val startedAt: String? = null, ) /** Summary content keys, in display order. */ @@ -132,6 +134,7 @@ fun parseJobs(raw: String): List = runCatching { error = o.optString("error", null).takeUnless { it.isNullOrBlank() }, stage = o.optString("stage", null).takeUnless { it.isNullOrBlank() }, progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null, + startedAt = o.optString("started_at", null).takeUnless { it.isNullOrBlank() }, ) } }.getOrDefault(emptyList())