From e04b003bf82177aa5036d1507830780981cf4482 Mon Sep 17 00:00:00 2001 From: Win Dictation Dev Date: Thu, 11 Jun 2026 19:08:31 +1200 Subject: [PATCH] Fix 05: Remove g_lastLoadedText poisoning bug; add live session archiving via FinalizeSession --- history-not-populating-fix.md | 317 ++++++++++++++++++++++++++++++++++ src/main.cpp | 44 +++-- 2 files changed, 347 insertions(+), 14 deletions(-) create mode 100644 history-not-populating-fix.md diff --git a/history-not-populating-fix.md b/history-not-populating-fix.md new file mode 100644 index 0000000..663b407 --- /dev/null +++ b/history-not-populating-fix.md @@ -0,0 +1,317 @@ +# Fix 05 — New transcriptions never reach history (+ live session archiving) + +**Builds on:** Fix-03/04 (popup history list — those are fine and unchanged). +**Files touched:** `src/main.cpp` only. `history.h` already has everything we need. +**Estimated effort:** 30–45 minutes including testing. + +--- + +## 1. Root cause — one line poisons every archive path + +History files are only ever written by `ArchiveSession()`, which is called from three +places, all guarded the same way: + +| Where | Guard | +|---|---| +| Clear button (`OnClick` → `WK::Clear`) | `if (!cur.empty() && cur != g_lastLoadedText)` | +| Picking a history entry (`WM_APP_SELECT` → `ID_SEL_HISTORY`) | same | +| App exit (`WM_DESTROY`) | same | + +`g_lastLoadedText` exists for ONE purpose: it remembers text that was loaded **FROM** +history, so that flipping between entries doesn't re-archive an unmodified copy and +create duplicates. It is supposed to be set in exactly one place — the history-load +handler. + +But look at `WM_APP_RESULT` (the handler that runs when a transcription finishes). +After inserting the new text into the transcript box it does: + +```cpp +UpdatePlaceholder(hWnd); +g_lastLoadedText = GetEditText(hWnd); // ← THE BUG +SetClipboardTextUtf8(hWnd, *res); +``` + +That line stamps the freshly-transcribed text as "this came from history". From that +moment, `cur == g_lastLoadedText` is true, so: + +- **Clear** skips the archive — but still shows the (now lying) "Saved to history" status; +- **loading another history entry** silently discards the current transcription; +- **exiting the app** discards it too. + +Net effect: exactly what you reported — no new transcription ever lands in `history\`. +You can confirm the diagnosis before fixing: transcribe → **manually type one extra +character** in the box → press Clear → the entry DOES appear (the edit makes +`cur != g_lastLoadedText` again). + +## 2. Second problem — sessions only archived at boundaries the user rarely hits + +Even with that line deleted, a session is only archived on Clear / entry-switch / exit. +The real dictation workflow is: hotkey → speak → auto-paste → keep working, app lives in +the tray. Clear is optional, exit is rare. So history would still feel "missing" most of +the time, and a crash would lose the whole session. + +**Fix: live session archiving.** Every successful transcription immediately writes the +session's history file: + +- the **first** clip of a session **creates** a new timestamped file (via the existing + `ArchiveSession`, which already returns the path it wrote); +- every **subsequent** clip **rewrites that same file** with the full transcript — + one file per session, never duplicates; +- Clear / entry-switch / exit just *finalize* the session (capture any manual edits made + after the last clip, then start a fresh session). + +Result: open the History popup right after dictating and the session is already there, +at the top, kept current as you append. Crash-safe for free. + +--- + +## 3. Step 1 — Add the session-path global + +In `src/main.cpp`, find: + +```cpp +std::vector g_history; +std::wstring g_lastLoadedText; +``` + +Add one line below them: + +```cpp +std::vector g_history; +std::wstring g_lastLoadedText; +std::wstring g_sessionPath; // history file backing the CURRENT session ("" = none yet) +``` + +**Do NOT delete `g_lastLoadedText`** — it still guards against re-archiving an +unmodified entry that was loaded from history (Step 4 below still uses it). + +## 4. Step 2 — Add two small helpers + +Paste these **immediately above** `void OnClick(HWND hWnd, WK kind)` (right after the +`SelectsRowFullWidthAnchor()` helper from Fix-04): + +```cpp +// True if the string contains anything that isn't whitespace. +static bool HasInk(const std::wstring& s) { + for (wchar_t c : s) if (!iswspace(c)) return true; + return false; +} + +// End the current session: make sure whatever is in the transcript box is in +// history (including manual edits made after the last clip), then reset the +// session so the next transcription starts a new history file. +// Returns true if the session is saved in history. +static bool FinalizeSession(HWND hWnd) { + std::wstring cur = GetEditText(hWnd); + bool saved = false; + if (!g_sessionPath.empty()) { + // Live archiving already created the file; just capture any edits + // the user made after the last transcription. + if (HasInk(cur)) WriteFileUtf8(g_sessionPath, cur); + saved = true; + } else if (HasInk(cur) && cur != g_lastLoadedText) { + // Text that was typed (never transcribed) — archive it once. + // The g_lastLoadedText guard stops unmodified loaded entries + // from being archived a second time. + saved = !ArchiveSession(cur).empty(); + } + g_sessionPath.clear(); + return saved; +} +``` + +(`WriteFileUtf8` and `ArchiveSession` are both `inline` in `history.h`, which +`main.cpp` already includes — nothing to add there.) + +## 5. Step 3 — Fix `WM_APP_RESULT` (delete the bug, add live archiving) + +In `WndProc`'s `WM_APP_RESULT` case, find these four lines: + +```cpp + SendMessageW(hEdit, EM_SCROLLCARET, 0, 0); + UpdatePlaceholder(hWnd); + g_lastLoadedText = GetEditText(hWnd); + SetClipboardTextUtf8(hWnd, *res); +``` + +Replace with: + +```cpp + SendMessageW(hEdit, EM_SCROLLCARET, 0, 0); + UpdatePlaceholder(hWnd); + { + // Live-archive: keep this session's history file current. + // First clip creates the file; later clips rewrite it. + std::wstring all = GetEditText(hWnd); + if (g_sessionPath.empty()) g_sessionPath = ArchiveSession(all); + else WriteFileUtf8(g_sessionPath, all); + } + SetClipboardTextUtf8(hWnd, *res); +``` + +Two things happened here — make sure both did: + +1. `g_lastLoadedText = GetEditText(hWnd);` is **GONE**. This is the actual bug fix. + Do not move it somewhere else; it must only ever be assigned in the history-load + handler (Step 5b) and cleared on Clear. +2. The live-archive block was added in its place. + +## 6. Step 4 — Route the three session boundaries through `FinalizeSession` + +### 4a. Clear button — `OnClick`, `case WK::Clear` + +Find: + +```cpp + case WK::Clear: { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) + ArchiveSession(cur); + g_history = LoadHistoryIndex(); + g_lastLoadedText.clear(); + g_editDirty = false; + SetDlgItemText(hWnd, ID_EDIT_TEXT, L""); + UpdatePlaceholder(hWnd); + SetStatus(hWnd, L"Saved to history"); + break; + } +``` + +Replace with: + +```cpp + case WK::Clear: { + bool saved = FinalizeSession(hWnd); + g_history = LoadHistoryIndex(); + g_lastLoadedText.clear(); + g_editDirty = false; + SetDlgItemText(hWnd, ID_EDIT_TEXT, L""); + UpdatePlaceholder(hWnd); + SetStatus(hWnd, saved ? L"Saved to history" : L"Cleared"); + break; + } +``` + +(Bonus fix: the status no longer claims "Saved to history" when nothing was saved — +clearing an empty box now honestly says "Cleared".) + +### 4b. Picking a history entry — `WM_APP_SELECT`, the `ID_SEL_HISTORY` branch + +Find: + +```cpp + } else if (ctrlId == ID_SEL_HISTORY && idx >= 0 && idx < (int)g_history.size()) { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) + ArchiveSession(cur); + std::wstring text = ReadFileUtf8(g_history[idx].path); +``` + +Replace the first three body lines with one call (the rest of the branch stays): + +```cpp + } else if (ctrlId == ID_SEL_HISTORY && idx >= 0 && idx < (int)g_history.size()) { + FinalizeSession(hWnd); // save the in-progress session before swapping + std::wstring text = ReadFileUtf8(g_history[idx].path); +``` + +Leave `g_lastLoadedText = text;` and everything after it in this branch exactly as it +is — this is the ONE place `g_lastLoadedText` is supposed to be assigned. + +Note: loading an entry does NOT make it the live session (`FinalizeSession` cleared +`g_sessionPath`). If you dictate on top of a loaded entry, the next clip archives the +combined text as a **new** file — old history entries are never mutated. + +### 4c. App exit — `WM_DESTROY` + +Find: + +```cpp + case WM_DESTROY: { + std::wstring cur = GetEditText(hWnd); + if (!cur.empty() && cur != g_lastLoadedText) ArchiveSession(cur); + PersistNow(); + PostQuitMessage(0); + break; + } +``` + +Replace with: + +```cpp + case WM_DESTROY: { + FinalizeSession(hWnd); + PersistNow(); + PostQuitMessage(0); + break; + } +``` + +### 4d. Legacy hidden Clear button — `WM_COMMAND`, `case ID_BTN_CLEAR` + +This branch belongs to a hidden legacy child button and never fires, but update it to +match 4a anyway so the two Clear paths can't drift apart: + +```cpp + case ID_BTN_CLEAR: { + bool saved = FinalizeSession(hWnd); + g_history = LoadHistoryIndex(); + g_lastLoadedText.clear(); + g_editDirty = false; + SetDlgItemText(hWnd, ID_EDIT_TEXT, L""); + UpdatePlaceholder(hWnd); + SetStatus(hWnd, saved ? L"Saved to history" : L"Cleared"); + break; + } +``` + +## 7. How the pieces behave now (mental model for the dev) + +``` +record clip 1 ──► WM_APP_RESULT ──► ArchiveSession(text) → creates 2026-06-11_HHMMSS.txt, g_sessionPath = that file +record clip 2 ──► WM_APP_RESULT ──► WriteFileUtf8(sessionPath) → same file rewritten with full text +edit by hand ──► (nothing yet — captured at the next clip or at finalize) +Clear / pick entry / exit ──► FinalizeSession → final write incl. edits, g_sessionPath = "" +next clip ──► new session file +``` + +- One file per session. Appending clips never creates duplicates. +- Cancelled clips / "No speech detected" change nothing (the result is empty, so the + live-archive block isn't reached). +- The session file keeps its creation timestamp/filename while it grows — it sorts in + the popup by when the session *started*. That's intended. +- `ArchiveSession` already refuses whitespace-only text and prunes to 100 files; both + behaviors are reused untouched. + +## 8. Build & test checklist + +1. **The headline fix:** launch with an empty box → dictate one clip → open History + (no Clear!) → the new session is the top entry with the right preview. +2. **Appending:** dictate a second clip → open History → still ONE entry for this + session, now containing both clips. No duplicate rows. +3. **Clear:** press Clear → "Saved to history" → box empties → dictate again → History + now shows TWO entries (old session + new session). +4. **Clear with empty box:** status says "Cleared" and no empty file appears in `history\`. +5. **Exit:** dictate → edit a word by hand → Exit via tray → relaunch → the entry + contains the hand-edit. +6. **Switching:** dictate → open History → pick an older entry → the in-progress + session was saved (visible in the list) and the older text loads. +7. **No duplicate on unmodified load:** load an entry, change nothing, press Clear → + no new file is created (status "Cleared"); the entry appears once in the list. +8. **Dictating onto a loaded entry:** load an entry → dictate → a NEW combined entry is + created; the original old file is unchanged. +9. **Typed-only session:** type text manually without dictating → Clear → archived once. +10. **Cancel:** record → hotkey again mid-transcription to cancel → no history file. +11. **Diagnosis confirmation (optional, before applying the fix):** on the OLD build, + transcribe → type one character → Clear → entry appears. That proves the + `g_lastLoadedText` poisoning was the culprit. + +## 9. Do NOT touch + +- `history.h` — `ArchiveSession`, `WriteFileUtf8`, `PruneHistory`, `LoadHistoryIndex` + all unchanged. +- The popup code from Fix-03/04 (`PopupProc`, `ShowSelectPopup`, + `SelectsRowFullWidthAnchor`) — unchanged. +- `g_lastLoadedText` — keep it; it is still assigned in the history-load branch and + cleared on Clear. Just never assign it anywhere else (that was the bug). +- `g_editDirty` — currently informational only; leave as is. diff --git a/src/main.cpp b/src/main.cpp index 93aa0b5..33b65c6 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -144,6 +144,7 @@ DWORD g_insStart = 0, g_insEnd = 0; REAL g_statsTopY = 0; std::vector g_history; std::wstring g_lastLoadedText; +std::wstring g_sessionPath; #define ID_SEL_HISTORY 1019 enum class View { Main, Settings }; View g_view = View::Main; @@ -1130,6 +1131,24 @@ static RectF SelectsRowFullWidthAnchor() { return RectF(a.X, b.Y, (b.X + b.Width) - a.X, b.Height); } +static bool HasInk(const std::wstring& s) { + for (wchar_t c : s) if (!iswspace(c)) return true; + return false; +} + +static bool FinalizeSession(HWND hWnd) { + std::wstring cur = GetEditText(hWnd); + bool saved = false; + if (!g_sessionPath.empty()) { + if (HasInk(cur)) WriteFileUtf8(g_sessionPath, cur); + saved = true; + } else if (HasInk(cur) && cur != g_lastLoadedText) { + saved = !ArchiveSession(cur).empty(); + } + g_sessionPath.clear(); + return saved; +} + void OnClick(HWND hWnd, WK kind) { if (!g_modelLoaded.load() && kind != WK::RecordHero && kind != WK::Clear && kind != WK::Copy && kind != WK::Paste && kind != WK::Pin @@ -1161,15 +1180,13 @@ void OnClick(HWND hWnd, WK kind) { } break; case WK::Clear: { - std::wstring cur = GetEditText(hWnd); - if (!cur.empty() && cur != g_lastLoadedText) - ArchiveSession(cur); + bool saved = FinalizeSession(hWnd); g_history = LoadHistoryIndex(); g_lastLoadedText.clear(); g_editDirty = false; SetDlgItemText(hWnd, ID_EDIT_TEXT, L""); UpdatePlaceholder(hWnd); - SetStatus(hWnd, L"Saved to history"); + SetStatus(hWnd, saved ? L"Saved to history" : L"Cleared"); break; } case WK::Pin: @@ -1667,7 +1684,11 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) SendMessageW(hEdit, EM_REPLACESEL, TRUE, (LPARAM)ins.c_str()); SendMessageW(hEdit, EM_SCROLLCARET, 0, 0); UpdatePlaceholder(hWnd); - g_lastLoadedText = GetEditText(hWnd); + { + std::wstring all = GetEditText(hWnd); + if (g_sessionPath.empty()) g_sessionPath = ArchiveSession(all); + else WriteFileUtf8(g_sessionPath, all); + } SetClipboardTextUtf8(hWnd, *res); @@ -1736,9 +1757,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) InvalidateRect(GetDlgItem(hWnd, ID_SEL_MODEL), nullptr, FALSE); PersistNow(); } else if (ctrlId == ID_SEL_HISTORY && idx >= 0 && idx < (int)g_history.size()) { - std::wstring cur = GetEditText(hWnd); - if (!cur.empty() && cur != g_lastLoadedText) - ArchiveSession(cur); + FinalizeSession(hWnd); std::wstring text = ReadFileUtf8(g_history[idx].path); SetWindowTextW(GetDlgItem(hWnd, ID_EDIT_TEXT), text.c_str()); g_lastLoadedText = text; @@ -1782,15 +1801,13 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) PostMessage(hWnd, WM_HOTKEY, HK_TOGGLE, 0); break; case ID_BTN_CLEAR: { - std::wstring cur = GetEditText(hWnd); - if (!cur.empty() && cur != g_lastLoadedText) - ArchiveSession(cur); + bool saved = FinalizeSession(hWnd); g_history = LoadHistoryIndex(); g_lastLoadedText.clear(); g_editDirty = false; SetDlgItemText(hWnd, ID_EDIT_TEXT, L""); UpdatePlaceholder(hWnd); - SetStatus(hWnd, L"Saved to history"); + SetStatus(hWnd, saved ? L"Saved to history" : L"Cleared"); break; } case ID_BTN_PIN: @@ -1844,8 +1861,7 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) return 0; case WM_DESTROY: { - std::wstring cur = GetEditText(hWnd); - if (!cur.empty() && cur != g_lastLoadedText) ArchiveSession(cur); + FinalizeSession(hWnd); PersistNow(); PostQuitMessage(0); break;