WIP snapshot: summary-ready toast + unseen badge (safety commit before power loss; sibling session actively editing — may not compile yet)

This commit is contained in:
avi 2026-09-17 22:17:59 -05:00
commit add622d649
5 changed files with 173 additions and 2 deletions

View file

@ -170,6 +170,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
prefs.getString(KEY_SORT)?.takeIf { it in SORT_ORDERS }
?.let { _sortOrder.value = it }
_summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto"
loadNotifyState()
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
?.let { setFolder(it, silent = true) }
ensureReady()
@ -641,6 +642,76 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
private val _liveProgress = MutableStateFlow<Map<String, LiveProgress>>(emptyMap())
val liveProgress = _liveProgress.asStateFlow()
// ---- summary-ready notifications ------------------------------------------
// One-shot toast ("Summary ready — <file>", auto-dismiss) plus a per-file
// "new summary" badge that clears when the recording is opened. Announce
// happens exactly once per (file, summary version): the toast fires on the
// completion path wherever it runs (pump, reprocess, adoption, or opening
// the detail screen on a finished job), and the unseen set persists in
// settings so a finish right before quitting still shows the badge later.
data class SummaryToast(val label: String, val file: File)
private val _summaryToast = MutableStateFlow<SummaryToast?>(null)
val summaryToast = _summaryToast.asStateFlow()
private val _unseenSummaries = MutableStateFlow<Set<String>>(emptySet())
val unseenSummaries = _unseenSummaries.asStateFlow()
private val announcedSummaries = mutableSetOf<String>()
private var summaryToastJob: Job? = null
private suspend fun loadNotifyState() {
_unseenSummaries.value =
SummaryNotify.decode(prefs.getString(KEY_UNSEEN_SUMMARIES))
announcedSummaries.addAll(
(prefs.getString(KEY_ANNOUNCED_SUMMARIES) ?: "")
.split("\n").filter { it.isNotBlank() })
}
/** Call when a summarize job for [file] reached `succeeded` with [version].
* The badge is skipped when the detail screen is open on this very file
* — the user is watching it finish; only the toast fires. */
private fun announceSummaryReady(file: File, version: Int) {
val key = SummaryNotify.seenKey(file.name, version)
if (!announcedSummaries.add(key)) return
scope.launch {
prefs.putString(KEY_ANNOUNCED_SUMMARIES,
SummaryNotify.trimSeen(announcedSummaries.toList()).joinToString("\n"))
}
// Suppress the badge only while the user is actually looking at this
// recording (the toast still fires). _detail survives navigating
// away, so the screen must be checked too.
if (!(_screen.value == Screen.DETAIL && _detail.value?.file == file)) {
_unseenSummaries.value = _unseenSummaries.value + file.name
scope.launch { persistUnseen() }
}
_summaryToast.value = SummaryToast("Summary ready — ${file.nameWithoutExtension}", file)
summaryToastJob?.cancel()
summaryToastJob = scope.launch {
delay(12_000)
if (_summaryToast.value?.label ==
"Summary ready — ${file.nameWithoutExtension}")
_summaryToast.value = null
}
}
/** Toast "View": open the finished recording (clears its badge). */
fun viewSummaryReady(toast: SummaryToast) {
_summaryToast.value = null
if (toast.file.isFile) openDetail(toast.file)
}
fun dismissSummaryToast() { _summaryToast.value = null }
/** Called from openDetail: the badge for this file is now acknowledged. */
private fun clearUnseenSummary(file: File) {
if (file.name !in _unseenSummaries.value) return
_unseenSummaries.value = _unseenSummaries.value - file.name
scope.launch { persistUnseen() }
}
private suspend fun persistUnseen() {
prefs.putString(KEY_UNSEEN_SUMMARIES,
SummaryNotify.encode(_unseenSummaries.value))
}
private fun setLive(name: String, live: LiveProgress?) {
_liveProgress.value = _liveProgress.value.toMutableMap().apply {
if (live == null) remove(name) else put(name, live)
@ -730,6 +801,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
.getOrNull() ?: return false
val summary = runCatching { parseSummary(provider.fetchSummary(refKey)) }.getOrNull()
saveReport(DetailUi(file = f, transcript = transcript, summary = summary))
summary?.let { announceSummaryReady(f, it.version) }
true
} catch (e: Exception) {
setLive(f.name, null)
@ -804,6 +876,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
if (transcript != null || summary != null) {
saveReport(DetailUi(file = f, transcript = transcript, summary = summary))
}
summary?.let { announceSummaryReady(f, it.version) }
rescanStatuses()
}
@ -1258,6 +1331,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
fun openDetail(file: File) {
pollJob?.cancel()
clearUnseenSummary(file)
// Show the saved report immediately — otherwise opening a finished
// file looks empty and the summary appears unreachable.
val saved = runCatching { reportFile(file).readText() }.getOrNull()
@ -1585,6 +1659,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
.getOrNull()
d0.busy = null
_detail.value = d0.copy()
d0.summary?.let { announceSummaryReady(d0.file, it.version) }
if (d0.transcript != null || d0.summary != null) {
saveReport(d0)
saveMapping(d0.file, RemoteMapping(remoteId, model = d0.overrideModel,
@ -1741,6 +1816,8 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
}
/** Summarizer choice: "auto" | "lan" | "local" (see setSummarizer). */
const val KEY_SUMMARIZER = "llm.summarizer"
const val KEY_UNSEEN_SUMMARIES = "notify.unseen_summaries"
const val KEY_ANNOUNCED_SUMMARIES = "notify.announced_summaries"
/** Preferred local summarization model (Ollama). */
const val DEFAULT_LLM_MODEL = "qwen3:4b"
/** LAN H200 inference server (private 10.x network, never internet). */

View file

@ -1,7 +1,40 @@
package com.shonar.desktop
import org.json.JSONArray
import java.io.File
/**
* Pure summary-completion notification logic (unit-tested; no coroutines).
*
* Two signals for a finished summary: a one-shot toast (fires where the job
* completed, wherever that is in the app) and a persistent per-file badge
* ("new summary") that stays until the user opens that recording. The unseen
* set persists across restarts so a summary finished right before closing
* the app still greets you. Dedupe is per (file, summary version) so a
* re-summarize re-announces but repeated polling of the same v does not.
*/
object SummaryNotify {
/** unseen set <-> settings.json string (JSON array of file names). */
fun encode(names: Set<String>): String {
val arr = JSONArray()
names.sorted().forEach { arr.put(it) }
return arr.toString()
}
fun decode(raw: String?): Set<String> = runCatching {
val arr = JSONArray(raw ?: "[]")
(0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }
.toSet()
}.getOrDefault(emptySet())
/** dedupe key so one summary version announces exactly once. */
fun seenKey(fileName: String, version: Int): String = "$fileName#$version"
/** keep the persisted "already announced" list bounded. */
fun trimSeen(seen: List<String>, max: Int = 200): List<String> =
if (seen.size <= max) seen else seen.takeLast(max)
}
/**
* Pure library-queue logic (unit-tested; no coroutines, no Android).
*

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.Text
import androidx.compose.material3.TextButton
@ -151,7 +152,30 @@ fun main() = application {
// (a delete from Detail lands back on Library,
// but the toast must appear either way).
val undoToast by state.undoTrash.collectAsState()
val summaryToast by state.summaryToast.collectAsState()
Column(Modifier.fillMaxSize()) {
summaryToast?.let { t ->
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Row(Modifier.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(t.label,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f))
TextButton({ state.viewSummaryReady(t) }) {
Text("View")
}
TextButton({ state.dismissSummaryToast() }) {
Text("✕")
}
}
}
Spacer(Modifier.height(8.dp))
}
undoToast?.let { t ->
Card(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth()

View file

@ -198,6 +198,7 @@ fun FolderPickerDialog(
fun LibraryScreen(state: DesktopState) {
val folder by state.folder.collectAsState()
val allEntries by state.entries.collectAsState()
val unseenSummaries by state.unseenSummaries.collectAsState()
val statusFilter by state.libraryFilter.collectAsState()
// Status lens over the sorted list (counts above also follow it so
// "Transcribe all (N)" reflects what's visible when filtered).
@ -437,12 +438,16 @@ fun LibraryScreen(state: DesktopState) {
"failed" + (e.statusNote?.let { " ($it)" } ?: "")
FileStatus.NEW -> "not transcribed"
},
) + if (state.sortOrder.collectAsState().value != "name")
) + (if (e.file.name in unseenSummaries)
// Summary finished while you were away;
// clears when you open this recording.
" • ✦ new summary"
else "") + (if (state.sortOrder.collectAsState().value != "name")
// Date sorts: show what they sorted by.
" • " + java.time.LocalDate.ofInstant(
java.time.Instant.ofEpochMilli(e.file.lastModified()),
java.time.ZoneId.systemDefault())
else ""),
else "")),
style = MaterialTheme.typography.bodySmall,
color = when (e.status) {
FileStatus.FAILED -> MaterialTheme.colorScheme.error

View file

@ -0,0 +1,32 @@
package com.shonar.desktop
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class SummaryNotifyTest {
@Test fun `encode-decode roundtrip is sorted and stable`() {
val names = setOf("b.m4a", "a.m4a")
assertEquals("""["a.m4a","b.m4a"]""", SummaryNotify.encode(names))
assertEquals(names, SummaryNotify.decode(SummaryNotify.encode(names)))
}
@Test fun `decode tolerates garbage and null`() {
assertEquals(emptySet(), SummaryNotify.decode(null))
assertEquals(emptySet(), SummaryNotify.decode("not json"))
assertEquals(setOf("x.m4a"), SummaryNotify.decode("""["x.m4a",""]"""))
}
@Test fun `seenKey dedupes per version`() {
assertEquals("f.m4a#3", SummaryNotify.seenKey("f.m4a", 3))
assertTrue(SummaryNotify.seenKey("f.m4a", 3) != SummaryNotify.seenKey("f.m4a", 4))
}
@Test fun `trimSeen keeps the newest entries`() {
val many = (1..300).map { "k$it" }
val trimmed = SummaryNotify.trimSeen(many, max = 200)
assertEquals(200, trimmed.size)
assertEquals("k300", trimmed.last())
assertTrue("k1" !in trimmed)
}
}