- scripts/update-app.sh: detached updater (setsid, survives app exit):
git pull atitlan master -> gradle createDistributable -> only then
kill app+engine and relaunch the fresh binary. Pull or build failure
leaves the running app alone. Log: ~/.config/shonar-desktop/logs/update.log.
- Settings → About & updates: 'Install update' button appears when the
check finds a newer remote commit; hands off to the script and tells
the user the app will relaunch itself.
- Desktop entry (~/.local/share/applications/shonar-desktop.desktop,
outside the repo): launches the packaged distributable via
launch-desktop.sh; SHONAR_REPO fallback already resolves the tree,
so the packaged app spawns the engine and sees the updater script.
Gate: :app:compileKotlin + :app:test green; desktop-file-validate clean;
launcher verified (app window from the menu path); updater fail-safe
verified (dirty-tree pull refused, app survived).
Bakes the source commit + build timestamp into the jar
(generateBuildInfo task -> shonar-build.properties on the classpath).
The Settings screen gains a 'Check for updates' button that queries the
anonymous Forgejo commits API for the latest master commit and compares
it to this build: up to date / update available (with commit shas and an
'Open repo' link via xdg-open) / 'newer than forgejo — local work not
yet pushed' for dev builds ahead of the remote. Network failures render
as a message with a repo link, never a crash.
Gate: :app:compileKotlin + :app:test green (9 suites); jar property
verified by unzip.
SonarCard's Surface lays un-wrapped siblings in a Box, so the whole
processing panel (teal headline, gray elapsed/note text, stage rows,
progress bar) drew stacked on one line — the 'text overlapping while
summarizing' garble, visible only mid-job. Every other card call site
wraps its content; this was the one that didn't. Headline also gets
weight(1f, fill=false) + maxLines/Ellipsis so it can't collide with
the elapsed/ETA text at narrow widths.
THE root cause behind "summary changed but the open screen kept the
old voice until I left to the dashboard and back": the app mutates ONE
DetailUi instance in place and republishes it with copy(). StateFlow
drops any assignment equal() to its current value — and a copy of the
very object it already holds is ALWAYS equal (same mutated fields).
So every publish after the first was silently dropped. The screen
only ever refreshed by accident, when some other collected flow
(models, playback, library rows) happened to trigger a recomposition
that re-read the mutated fields mid-run; the final summary publish,
with nothing else changing at that moment, just vanished. Engine
logs proved the app fetched the fresh sarcastic summary — the UI
received it and threw it away.
DetailUi gains a rev discriminator bumped on every publish (23 sites),
so no update can ever conflate away. Two regression tests pin the
conflation semantics.
Between pressing Transcribe and the first /jobs poll there was a blind
window where real work (POST in flight, token refresh, initial upload)
had no on-screen trace at all — the screenshot complaint 'shows
nothing'. Any non-null busy label now renders as a pipeline-style card
in the same slot the panel occupies, so feedback starts at click time.
reprocessInPlace's poll loop swallowed fetchJobs failures as an empty
list and broke on the resulting missing status, so ONE dropped poll
during a 10-minute summarize ended the watch early: chips stayed
disabled, the screen showed stale content, and only leaving to the
library and back (fresh openDetail fetch) displayed the finished
summary — the exact reported symptom.
Now only a successful fetch reporting a terminal state ends the loop;
transient failures keep polling for ~30s, and a genuinely-lost engine
says so in plain words instead of silently stranding the screen. A
missing job row on healthy fetches gets 3 grace cycles before giving
up (engine rewrites rows mid-requeue).
Opening a recording whose live summary row isn't loaded fell through
to sumTone (null = Neutral), so the pill showed Neutral while the
report text on screen read "*Voice: funny*". chipTone now reads the
report's own *Voice: X* metadata line as the fallback, so the chips
can never contradict the artifact being displayed. Running job and
live summary rows still take precedence. 3 new tests (reportVoice).
Replaces the animated bars that moved without meaning anything with a
stage panel wired to engine truth only:
- Upload: real bytes fraction (backend measures it).
- Transcription: real audio-time percent + ETA (15-95% window only;
outside it the ETA is withheld as noise).
- Summary: stage markers only — the engine's summarize percent means
"streaming" (thinking models hit 99 on the first token), so no pct
and no ETA are shown for it, ever. "waiting for model" vs "writing
in a dry, witty voice · streaming reply" vs "queued on the
summarizer" come from the actual job row.
- Finalizing: engine done, app hasn't landed the fresh result yet.
- Header shows elapsed (and ETA where derivable) counting from the
job's real started_at, parsed as server UTC.
- The line beside the voice chips mirrors the Summary stage so a
clicked chip always gets visible feedback even if the panel is
scrolled away.
Rows flip only when the engine actually moves a stage. No timers,
no decoration pretending to be progress. 6 new unit tests pin the
contract (65 frontend tests, 0 failures).
The long saved report pushed the bottom Export out of sight. It now
sits beside Re-transcribe right under the voice chips, and stays at
the bottom for the end-of-document flow.
Every remaining screen routes through the shared primitives: Detail's
header nav (ghost links + quiet-red Delete), playback transport,
in-recording search field, voice PillChips, model picker and transcript
edit; Settings' folder row, summarizer PillChips, and model cards
(default model gets the teal ◉); Engine's start/retry; the folder
picker's breadcrumbs and rows; and both app-wide toasts are SonarCards.
Export becomes a real dialog: pick Markdown or ODT + any folder
(exportTo in DesktopState — the library's own .transcript.md is never
touched). Unused Material widget imports removed app-wide.
Verified live: library screenshot shows the reference language end to
end (glowing selected pills, hairline cards, teal status text);
59 frontend tests green.
Library screen now uses the shared primitives: filled SonarField search
with leading magnifier, PillChip sort/show filters (selected pill gets
the teal glow), SonarCard rows with a "Your recordings — N of M"
section header per the reference, ghost icon row-actions, and the
quiet-red bulk Delete. Rename dialog rides the same field/buttons.
All handlers, selection, filtering, and batch logic untouched.
Tokens centralize the reference design language: near-black navy page,
one elevated surface, hairline borders, mint-teal accent reserved for
active/live/selected, soft glow only on the selected pill; radius,
4dp spacing scale, icon sizes, and the full type hierarchy (22/700
page title through 12/regular labels) all live in one place.
Components.kt adds the primitives every screen will use: SonarCard,
ShonarButton (filled mint primary), ShonarOutlinedButton,
ShonarGhostButton, ShonarDangerButton, PillChip (the reference's
selected-pill-with-glow), SonarField (filled dark input with teal
focus border), SectionHeader, HelperText. Material3 scheme derived
from the same tokens so leftover defaults inherit the language.
renderOdt builds the minimal valid ODF package (STORED mimetype first,
content.xml, manifest) mirroring the markdown report's sections so the
two exports can't drift in content. The Export .odt button lives once
for both detail paths (live transcript or library-opened saved report)
and writes <name>.report.odt next to the audio.
Tests: 5 new (package shape, section parity, XML escaping +
well-formedness, neutral fallback, filename). Verified in LibreOffice:
headings, bullet lists, and escaped text render natively.
Chips are disabled during a run, so selection followed the previous
summary's voice while the new job churned — Neutral grayed out next to
a highlighted sarcastic even though sarcastic was what was running.
Selection now prefers the running job's tone, then the live draft, then
the saved summary.
With milestone-backed progress the number is truthful, so replace the
indeterminate 'working' bar with a determinate one labeled 'Summarizing
<tone>… N%' (indeterminate spinner only while waiting for the first token).
- Engine: when the primary summarizer is out of retries (or misconfigured),
run_summarize now finishes the job on the configured rescue provider
(SHONAR_LLM_FALLBACK_*), tags the summary with the provider that wrote it,
and stores a human note in job.error; success clears stale notes.
- App (auto/lan): passes Ollama as the rescue provider when it is up.
- Detail screen: shows the rescue-swap note in plain words, a red
'Summary failed' line with Settings -> Summarizer fix instructions and a
Retry summary button on hard failure.
- Settings copy explains the fallback. 3 new pytest cases (14/14 pass);
live E2E on 2026-09-18: sarcastic summary v6 via LAN on attempt 2.
- Detail: pinned header (leave from anywhere), Prev/Next + Alt+arrows + Esc
- Summary above transcript with full sections (key points/decisions/action
items/details) + Copy summary / Copy action items buttons
- Voice chips moved under the summary; one-page scroll below the header
- Stuck-at-99% fixed: detail re-fetches transcript+summary when a watched
job finishes while the screen is open
- Playback: Pause/Resume + seek slider + position readout over mpv IPC
(socket verified; controls hide if IPC unavailable)
- Library: sort Name/Newest/Oldest (persisted, date shown per row) and
status filter All/Untranscribed/Done/Failed
- Delete is undoable: trash + 10s Undo toast replaces confirm dialogs
(single + bulk); startup sweeps stale trash; engine purge on undo-window
close