From 7074a4c57343777fe08f5cdd3174563a46007484 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 6 Sep 2026 15:07:35 +0200 Subject: fix: give the Sent and Drafts views one row per message, in date order Both views are lists of the user's own messages and are flat, but walkThreads() emitted one ThreadSummary per THREAD and then picked a single matched message to stand for it, breaking at the first one the oldest-first walk reached. A conversation replied to twice therefore produced one row: dated by the thread, opening the OLDER of the two messages, with the newer one reachable nowhere in the view. Reported against real mail, where a message sent at 12:42 was missing while the row above it, dated 12:42, opened a message from three weeks earlier. The same wrongly chosen message supplied firstMessagePath, so Delete or Archive on such a row would have moved a file the user was not looking at, silently, and mbsync would have carried it to the server. That half was never visible. The Sent branch now emits one summary per matched message, each carrying its own id, tags, sender, path, date and subject. withRecipients still selects the branch, so Sent and Drafts both get this and no second flag can disagree with the flat-mode flag. Ordering was a second defect under the same item, found by hand once the rows appeared: notmuch_query_set_sort is a THREAD sort, so every row of a thread inherits that thread's single position and an older reply drew above a newer one. Sorting each thread's rows in place is not enough either, since a message from another thread dated between them still cannot land between them. Flat rows are collected and sorted as one list before emitting. ThreadListModel::rowKeyFor() is the second consequence and would have broken quietly: two rows now share a threadId, and reconcile() keyed its QHash on exactly that, so a sync would have dropped one of them by a different route. It answers what makes a row unique, the message id in flat mode and the thread id otherwise. Tests cover both halves over new fixture threads F and G. The cross-thread ordering assertion passed for the wrong reason at first, because the existing fixture threads happen not to interleave; thread G exists to break that and failed the moment it was added. oldestFirstReversesTheOrder is corrected rather than satisfied: OLDEST_FIRST orders threads by their oldest message while NEWEST_FIRST orders by their newest, so the two lists mirror each other only while no thread's date span contains another's, which this fixture is the first to violate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jq9gXquUo9W4KXDagJXMmn --- .../2026-08-03-post-0.1.0-usability-closed.md | 134 +++++++++++++++++++++ .../plans/2026-08-03-post-0.1.0-usability.md | 1 + 2 files changed, 135 insertions(+) (limited to 'docs/superpowers') diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md index 26f90fa..af1de0c 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md @@ -9807,3 +9807,137 @@ where it is unless the user overrules the reasoning above. have their own. **Closed 2026-08-29**, unreleased. Star and Archive added to the ordinary branch; Archive left the main toolbar; `mark_all_read` stays there by the user's decision, and the assertion that it does is now in the suite. + +--- + +## 191. The Sent view collapses two messages you sent in one conversation into one row + +**Observed (user, 2026-09-06, by hand against real mail).** The Sent view for +one account showed a row dated 12:42 today whose subject was right, but whose +message pane rendered a message from three weeks earlier. The message actually +sent at 12:42 appeared nowhere in the view. Thunderbird, pointed at the same +IMAP folder, showed both. The user's own statement of what the view owes them +settled the fix: "the sent view is for my sent messages, if I sent 2 messages in +a thread, I want to see both, not threaded (as the view is not threaded), +correctly tagged and dated." + +**Two wrong diagnoses came first and are worth recording**, because both were +plausible and both were contradicted by measurement. The first read the `draft` +chip on the row and blamed item 165; the chip is real and unrelated. The second +read the row's date, found the message in `Sent/`, and concluded the row was +correct and the user was comparing a thread count against a message count; the +counts do differ for that reason, but that was not what they were reporting. The +user's correction, that the pane opened a different message from the one the row +claimed, is what located the defect. **A row's date and the message its pane +opens are two separate reads, and a defect can sit precisely in the gap.** + +**Cause, verified in the code and against the real index.** +`NotmuchWorker::walkThreads()` (`notmuchworker.cpp`) is built on +`notmuch_query_search_threads`, so it emitted exactly one `ThreadSummary` per +thread. The Sent branch then chose ONE message to stand for that thread: + +``` +if (matched) { summary.firstMessageId = ...; break; } +``` + +`notmuch_thread_get_messages` walks oldest-first, so a thread the user had +replied to twice took their OLDEST reply and stopped. The row's `date` and +`subject` came from the thread (`notmuch_thread_get_newest_date`, +`notmuch_thread_get_subject`) while `firstMessageId` named that older message, +which is why the two disagreed on screen. + +Measured on the developer's database: the conversation reported +`[2/9]` — two matched messages of nine — and `notmuch search --output=messages +--sort=oldest-first` over the sent folder returned the 17 Aug message before the +6 Sep one. The view is already flat (`setFlatMode(m_sentView)`, +`mainwindow.cpp`, and `Config::generatorIsFlat` marks `sent` and `drafts`), so +the model was right about being unthreaded and only the worker was wrong. + +**This was also a data-safety defect, which the visible symptom hid.** +`firstMessagePath`, `firstMessageTags` and `firstMessageSender` were all read +from the same wrongly chosen message. `moveMessages` composes its destination +from that path, so Delete or Archive on such a row would have moved the OLDER +message's file, silently, and mbsync would have carried it to the server. The +row the user was looking at named a file they were not looking at. + +**Fix.** The Sent branch emits one `ThreadSummary` per matched message rather +than one per thread: it no longer breaks, and for each matched message it copies +the thread-wide summary and overrides the row's identity — +`firstMessageId`, `firstMessageTags`, `firstMessageSender`, `firstMessagePath`, +and now `date` and `subject`, read from the message with +`notmuch_message_get_date` and `notmuch_message_get_header`. Batching and the +`total` counter move inside that loop, and the thread iteration `continue`s +past the single-summary append below. + +`withRecipients` continues to select the branch, so Sent and Drafts both get +this and no new flag can disagree with the flat-mode flag: the two questions are +answered by one value, which is why the original branch keyed on it. + +**One consequence needed a second change, and it is the part that would have +broken quietly.** Two rows now share a `threadId`, and +`ThreadListModel::reconcile()` keyed its `QHash present` on exactly +that. Two rows mapping to one key means the second looks like a thread that has +vanished, so a sync would have dropped one of the user's sent messages from the +view again, by a different route. `ThreadListModel::rowKeyFor()` is the single +answer to "what makes a row unique": the message id in flat mode, the thread id +otherwise, falling back to the thread id when a flat row carries no message id. +All four keyed sites in `reconcile()` use it. + +**Verification.** Two tests in `test_notmuchworker.cpp`, over a new fixture +thread F (`f1` sent, `f2` received, `f3` sent) which is the shape the defect +needs and which no existing fixture had: + +- `aSentQueryEmitsOneRowPerMatchedMessage` asserts both ids are present, that + the two rows share a thread id (the property that made the reconcile change + necessary), and that the SAME messages under a non-Sent query still fold into + one row, so this is the flat branch's contract and not a change of meaning + for threaded views. +- `aSentRowCarriesItsOwnMessagesDateAndSubject` asserts each row's date and + subject are its own, which is the half the user saw first. + +`aSentQueryCarriesTheMatchedMessageNotTheThreadsFirst` was RETARGETED rather +than retired: its assertion still holds, but it filtered rows by the thread's +subject, and a flat row is now titled by its own message, so it matched nothing. +It filters on the message id instead and asserts the subject. + +Eight other failures were the new fixture's arithmetic, not the change: three +messages and one thread added, so hardcoded totals moved 5→6 threads and 6→9 +messages. + +**The ordering was a second defect under the same item, found by hand after +the first fix shipped to the user's screen.** With both rows present, the 17 +August one drew ABOVE the 6 September one. The cause is that +`notmuch_query_set_sort` is a THREAD sort: it orders the threads the walk +visits and says nothing about the messages inside one, so every row of a thread +inherits that thread's single position. Sorting each thread's own rows in place +is NOT enough either, and the fixture proved it: a message from another thread +dated between two of a thread's replies still cannot land between them. A flat +view is a list of messages, so `walkThreads()` collects flat rows in `flatRows` +and sorts the whole result once, by each row's own date, before emitting. +`std::stable_sort`, so rows sharing a timestamp keep the walk's order rather +than swapping between identical queries. + +`sentRowsAreOrderedByTheirOwnDate` covers it, in both sort directions and +across threads. **The cross-thread half of it passed for the wrong reason at +first**: fixture threads D, E and F happen not to interleave, so per-thread +sorting satisfied a whole-view order check. Fixture thread G exists purely to +break that, dated between thread F's two messages, and the assertion failed +the moment it was added. A mutation check confirms the within-thread half fails +without the sort. + +**One existing test had to be corrected rather than satisfied, and the +correction is a fact about notmuch worth keeping.** +`oldestFirstReversesTheOrder` asserted the two sort directions produce exactly +reversed lists. They do not: `NOTMUCH_SORT_OLDEST_FIRST` orders threads by +their OLDEST message while `NEWEST_FIRST` orders them by their NEWEST, so the +lists mirror each other only while no thread's date span contains another's. +Thread F starts before thread G and ends after it, which is the first fixture +data to violate that. The test keeps the assertion that does hold +(`oldest.first()` is `newest.last()`) and replaces the mirror with a +monotonicity check. It passed on master and fails here for a reason that is not +a regression: the fixture finally contains the shape that distinguishes the two +sorts. + +**Still open and separate:** the four autosave revisions of that reply sitting +in `Drafts/` with distinct Message-IDs, which is item 165 and is what puts a +`draft` chip on the conversation. diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 8c55aad..43b241e 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -264,6 +264,7 @@ taking that too literally. | 188 | Does Empty trash respect the account selector? | question | XS | **answered 2026-08-29** by reading the code, no work needed. It does: `MainWindow::emptyTrash()` (`mainwindow.cpp:6567`) reads `m_accountBox->currentData()` and uses `allTrashQuery()` only for All accounts, and the confirmation names which. Recorded so the notes' question has an answer rather than sitting open | | 189 | The message bar carries only Reply, Forward and Delete | presentation | S | **done 2026-08-29**, unreleased. Star and Archive joined the bar's ordinary branch, Archive leaving the main toolbar as Delete did. `mark_all_read` deliberately did NOT move, at the user's decision: it is the one action that ignores the selection. Item 140's toolbar test listed `archive` as a list-wide action and had to be corrected, which is the classification this item changed. Section in the closed file. Original entry: Asks for Star (`flag`) and Archive on the bar, and raises Mark all read as a question. Two of the three are selection-scoped and fit the bar's rule as it stands; **`mark_all_read` does not**, since it deliberately ignores the selection and acts on every row in the view, which is the one action in the window that does. Needs a decision from the user on that one and on whether Archive LEAVES the main toolbar the way Delete did | | 190 | Mark spam is not on the message bar, and its icon was never chosen for one | presentation | XS | open, 2026-09-06, from the notes. The bar's ordinary branch carries Reply, Forward, Star, Archive, Delete after item 189 and `spam` is not among them, though it meets the bar's rule (selection-scoped, undoable). Two halves: put it on the bar, and settle the icon, which the note asks to be "a bug, or a skull, or something that signifies bad/evil" and which is `mail-mark-junk` today, chosen for a menu where the label carries the meaning. **Paired with 187**, which changes what the action DOES (moves the file); ordering is the user's call | +| 191 | The Sent view collapses two messages you sent in one conversation into one row | defect | S | **done 2026-09-06**, unreleased, from a hand test. The Sent and Drafts views are flat, but the worker emitted one summary per THREAD and picked a single matched message to stand for it, oldest-first. A conversation replied to twice showed one row, dated by the thread and opening the OLDER message, and the newer one was reachable nowhere. Also a data-safety defect: `firstMessagePath` named the wrong file, so Delete would have moved it. A second half, found by hand once the rows appeared: the sort notmuch applies is a THREAD sort, so both rows took their thread's position and an older reply drew above a newer one. Flat rows are now sorted as one list. Section in the closed file | Sizes are rough: XS under an hour, S a sitting, M a session. -- cgit v1.2.3