Settings: pick the summarizer (LAN H200 / laptop Ollama / auto); waiting label shows elapsed wait

This commit is contained in:
avi 2026-09-16 11:26:15 -05:00
commit d66c27463c
4 changed files with 153 additions and 17 deletions

View file

@ -3,6 +3,28 @@
Checkpoint for the standalone desktop app. Prepend a new dated section per Checkpoint for the standalone desktop app. Prepend a new dated section per
material change; keep history below. 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 ## 2026-09-15 — standalone desktop, Android fully removed
**Verified state (live this session):** **Verified state (live this session):**

View file

@ -39,6 +39,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -539,6 +540,15 @@ fun DetailScreen(state: DesktopState) {
var recQuery by remember { mutableStateOf("") } var recQuery by remember { mutableStateOf("") }
// Voice for the next summarize: null = Neutral (server default). // Voice for the next summarize: null = Neutral (server default).
var sumTone by remember(detail.file.name) { mutableStateOf<String?>(null) } var sumTone by remember(detail.file.name) { mutableStateOf<String?>(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<String?>(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 playingFile by state.playingFile.collectAsState()
val playError by state.playError.collectAsState() val playError by state.playError.collectAsState()
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
@ -697,7 +707,8 @@ fun DetailScreen(state: DesktopState) {
val label = when { val label = when {
it.status == "running" && it.progress != null -> it.status == "running" && it.progress != null ->
"Summarizing… ${it.progress}%" "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" else -> "Summary queued"
} }
Text(label, style = MaterialTheme.typography.bodyMedium, Text(label, style = MaterialTheme.typography.bodyMedium,
@ -727,22 +738,66 @@ fun DetailScreen(state: DesktopState) {
Button({ state.transcribe() }) { Text("Transcribe") } Button({ state.transcribe() }) { Text("Transcribe") }
} }
detail.transcript?.let { t -> detail.transcript?.let { t ->
Text("Transcript", style = MaterialTheme.typography.titleMedium) val remoteKnownForEdit = state.remoteIdFor(detail.file) != null
LazyColumn(Modifier.fillMaxWidth().weight(1f), Row(verticalAlignment = Alignment.CenterVertically) {
verticalArrangement = Arrangement.spacedBy(4.dp)) { Text("Transcript", style = MaterialTheme.typography.titleMedium)
if (t.segments.isNotEmpty()) { if (t.editedByUser) {
items(t.segments.size) { i -> Spacer(Modifier.width(8.dp))
val s = t.segments[i] Text("· edited by you",
Text("[${fmtTs(s.startSec)}] ${s.text}", style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.bodyMedium) color = MaterialTheme.colorScheme.primary)
} }
} else { Spacer(Modifier.weight(1f))
item { Text(t.text) } 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", val draft = editDraft
style = MaterialTheme.typography.bodySmall, if (draft == null) {
color = MaterialTheme.colorScheme.onSurfaceVariant) 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 -> detail.summary?.let { s ->
if (s.short.isNotBlank()) { if (s.short.isNotBlank()) {
@ -833,7 +888,9 @@ fun DetailScreen(state: DesktopState) {
append(when { append(when {
sStatus == "queued" -> "Queued…" sStatus == "queued" -> "Queued…"
sProg != null && sProg > 0 -> "Summarizing… $sProg%" 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") } sumTone?.let { append(" · $it") }
if (sAttempt > 0) if (sAttempt > 0)
@ -917,6 +974,7 @@ fun SettingsScreen(state: DesktopState) {
val modelsError = state.modelsError.collectAsState().value val modelsError = state.modelsError.collectAsState().value
val url by state.serverUrl.collectAsState() val url by state.serverUrl.collectAsState()
val folder by state.folder.collectAsState() val folder by state.folder.collectAsState()
val summarizer by state.summarizer.collectAsState()
var showPicker by remember { mutableStateOf(false) } var showPicker by remember { mutableStateOf(false) }
// Root is scrollable: in split-screen/short windows the model cards // Root is scrollable: in split-screen/short windows the model cards
@ -941,6 +999,33 @@ fun SettingsScreen(state: DesktopState) {
} }
} }
Spacer(Modifier.height(4.dp)) 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("Default transcription model", style = MaterialTheme.typography.titleMedium)
Text("Applies to future transcriptions. Each recording keeps the model it used.", Text("Applies to future transcriptions. Each recording keeps the model it used.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View file

@ -7,8 +7,10 @@ import org.junit.Test
class JobProgressTest { class JobProgressTest {
private fun job(type: String, status: String, stage: String? = null, progress: Int? = null) = private fun job(type: String, status: String, stage: String? = null, progress: Int? = null,
JobInfo(jobType = type, status = status, stage = stage, progress = progress) startedIso: String? = null) =
JobInfo(jobType = type, status = status, stage = stage, progress = progress,
startedAt = startedIso)
@Test fun `idle when nothing running`() { @Test fun `idle when nothing running`() {
assertNull(DesktopState.jobProgress(emptyList())) assertNull(DesktopState.jobProgress(emptyList()))
@ -55,4 +57,28 @@ class JobProgressTest {
assertEquals("summarizing… 37%", p?.label) assertEquals("summarizing… 37%", p?.label)
assertEquals(0.37f, p!!.fraction!!, 0.0001f) 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))
}
} }

View file

@ -57,6 +57,8 @@ data class JobInfo(
/** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */ /** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */
val stage: String? = null, val stage: String? = null,
val progress: Int? = 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. */ /** Summary content keys, in display order. */
@ -132,6 +134,7 @@ fun parseJobs(raw: String): List<JobInfo> = runCatching {
error = o.optString("error", null).takeUnless { it.isNullOrBlank() }, error = o.optString("error", null).takeUnless { it.isNullOrBlank() },
stage = o.optString("stage", 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, progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null,
startedAt = o.optString("started_at", null).takeUnless { it.isNullOrBlank() },
) )
} }
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())