# 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.