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:
parent
3061629dc5
commit
add622d649
5 changed files with 173 additions and 2 deletions
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
32
app/src/test/kotlin/com/shonar/desktop/SummaryNotifyTest.kt
Normal file
32
app/src/test/kotlin/com/shonar/desktop/SummaryNotifyTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue