Files
win-dictate/history-not-populating-fix.md
T

12 KiB
Raw Blame History

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: 3045 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 (OnClickWK::Clear) if (!cur.empty() && cur != g_lastLoadedText)
Picking a history entry (WM_APP_SELECTID_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:

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:

std::vector<HistoryEntry> g_history;
std::wstring g_lastLoadedText;

Add one line below them:

std::vector<HistoryEntry> 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):

// 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:

                SendMessageW(hEdit, EM_SCROLLCARET, 0, 0);
                UpdatePlaceholder(hWnd);
                g_lastLoadedText = GetEditText(hWnd);
                SetClipboardTextUtf8(hWnd, *res);

Replace with:

                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:

        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:

        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:

            } 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):

            } 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:

        case WM_DESTROY: {
            std::wstring cur = GetEditText(hWnd);
            if (!cur.empty() && cur != g_lastLoadedText) ArchiveSession(cur);
            PersistNow();
            PostQuitMessage(0);
            break;
        }

Replace with:

        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:

                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.hArchiveSession, 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.