summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md24
-rw-r--r--CLAUDE.md31
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md47
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md140
4 files changed, 175 insertions, 67 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec08727..e869346 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,17 @@ point at which they are stable.
nothing to exclude from and running it would mean the whole Maildir minus
one value.
+### Removed
+
+- **The conversation view.** Selecting a thread used to render the whole
+ conversation in the message pane, earlier messages as unexpandable stubs with
+ the last two opened. Selecting any row, a thread root or a reply, now renders
+ exactly one message.
+
+ It was also inconsistent: a thread root rendered the conversation only until
+ the thread had been expanded once, after which the identical click rendered a
+ single message. Read a thread by expanding it and walking the reply rows.
+
### Changed
- The dialog reporting configuration problems at startup now appears over the
@@ -30,6 +41,19 @@ point at which they are stable.
unchanged: a keybinding that is being ignored does, a notice such as "no sync
command configured" does not.
+### Upgrading
+
+Reading a thread now means expanding it and clicking down its replies. Nothing
+in your config changes, and no habit built on the reply rows is affected, but
+the pane will show one message where it used to show a conversation.
+
+Automatic mark-read still applies to the **whole thread**, so opening a thread
+root marks its replies read as well, including ones you have not displayed.
+That was consistent while a root rendered the conversation and is not any more.
+Set `mark_read_delay_ms` to a negative value under `[general]` to turn the
+behaviour off entirely if that matters to you; narrowing it to one message is
+open work.
+
## [0.20.0] - 2026-08-14
Anything on screen in the message pane can now be searched for by right-clicking
diff --git a/CLAUDE.md b/CLAUDE.md
index a9cf33c..c63e167 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -398,6 +398,26 @@ standalone program containing none of this project's code. A size assertion
there passes against both, and a mutation putting the bug back leaves the suite
green. Assert on the stored value, and leave the frame to a hand test.
+**`ThreadListModel::threadAt(int)` takes a ROW and is wrong for any index that
+might be a reply.** A tree numbers rows per parent, so a reply's `row()` indexes
+its siblings and `threadAt(current.row())` on the first reply of any thread
+returns the FIRST THREAD IN THE LIST. This shipped in `markCurrentThreadRead`,
+was mostly masked while the write it guarded was thread-wide, and became "a
+random message was marked read" the moment a fix scoped that write to one
+message (items 87 and 88). Reach the thread through the INDEX, never through a
+row number, unless you have already established the index is a thread row.
+
+The general rule is in the item 20 note further down, "anything keyed on a row
+NUMBER did not survive the port to a tree". This is a surviving instance, found
+by corrupting real mail rather than by reading, so treat every remaining
+`.row()` in `mainwindow.cpp` as suspect until checked.
+
+**A test for a mutation on a data-writing path must exercise the REPLY case,
+not only the root.** The reverted fix above was mutation-checked and green: it
+asserted on a root selection, which is the one case where `current.row()` is
+correct. A green mutation check proves the test can fail, not that it covers the
+case that matters.
+
**`test_mainwindow` can now drive a real worker, and three things about it will
waste a session each.** `WorkerBackedWindow` builds a throwaway notmuch
database and writes a `qtmaildir.conf` pointing at it; `wireWorker()` reads
@@ -487,10 +507,13 @@ loosen any of these without an explicit decision.
scheme-wide allow would let a hostile body reference `qtmaildir://anything` and be trusted.
Consequence: `MessageView` **must** call `setDocumentUrl()` with the same URL it gives
`setHtml()`, or nothing renders.
-- A whole thread is one HTML document in one web view (a `QWebEngineView` per message would
- spawn a Chromium render process each). That makes `cid:` ids collide across messages, so
- every reference is rewritten to `cid:<prefix>!<id>`. **A `cidPrefix` must never contain
- `!`** — it is the namespace separator.
+- The pane renders a LIST of messages into one HTML document in one web view (a
+ `QWebEngineView` per message would spawn a Chromium render process each). That makes `cid:`
+ ids collide across messages, so every reference is rewritten to `cid:<prefix>!<id>`.
+ **A `cidPrefix` must never contain `!`** — it is the namespace separator.
+ Since item 66 removed the conversation view every caller passes exactly ONE message, so the
+ collision cannot currently arise; the prefixing stays because the list-rendering path does,
+ and a security property must not rest on every caller happening to pass one item.
- Remote content grants are per-render and never sticky.
- **Attachment filenames are untrusted input.** Reduce to basename, strip separators, resolve
against the chosen directory, and refuse anything escaping it. Compare resolved paths as
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 ed88693..ff2bbba 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
@@ -4937,3 +4937,50 @@ means something.
**Item 66 did not reproduce**, which is recorded in that item rather than
worked around here. The test was not widened until it failed; the negative
result and what it narrows to are in item 66's own entry.
+
+## 66. Selecting a thread root leaves the message pane blank until a reply has been selected
+
+**Done 2026-08-14, unreleased.** Closed by removing the conversation view, at
+the user's request. Not the defect it was filed as.
+
+**What it actually was.** The original note described a blank pane. Two probes
+against a real database failed to reproduce that, and the user's own step-by-step
+account on 2026-08-14 showed the pane was never blank: clicking an unexpanded
+thread root rendered the whole CONVERSATION, stubs plus the last two messages
+expanded. Clicking a reply then rendered one message, and clicking back to the
+root rendered one message from then on.
+
+**The cause was timing, not a race.** `onThreadSelected` already preferred to
+load the root's own first message, but the model learned that message id only
+when the replies arrived (`ThreadNode::first`, populated in the
+`threadTreeLoaded` handler). A row that had never been expanded therefore had no
+id and fell through to a whole-thread render. The identical click behaved
+differently once the thread had been opened, which is what the user was
+reporting as inconsistency.
+
+**Fixed by carrying the id in the query.** `ThreadSummary::firstMessageId` comes
+from `notmuch_thread_get_toplevel_messages` during the query walk, so it is
+known before any expansion and the fallback is unreachable. Free: measured
+indistinguishable from not collecting it over a 36,615-thread database, because
+it reads the index rather than the message files. The Sent view takes the first
+message the query MATCHED instead, since a Sent row stands for what the user
+sent and not for whoever opened the thread; libnotmuch has no matched-messages
+iterator, only a count, so that branch walks to the first
+`NOTMUCH_MESSAGE_FLAG_MATCH` and stops.
+
+**The conversation view is gone, on the user's decision.** Asked whether to keep
+it reachable another way, the user declined: "I have no use for a view that
+shows the first n messages as stubs I can't expand and lets me see only the last
+two replies." Worth recording that the stubs not expanding was itself a defect,
+so the feature was judged in a broken state; the user chose removal anyway when
+told. `NotmuchWorker::loadThread` survives with no UI caller, documented as such,
+because it is a tested way to read a thread's messages with the match set
+resolved.
+
+**Two defects came out of this and are open as items 87 and 88.** Auto mark-read
+still marks the whole thread, which was coherent while a root rendered the
+conversation and is not any more; and `threadAt(current.row())` answers about
+the wrong thread for a reply row. The second was found the hard way: a fix for
+87 was written, mutation-checked, shipped and reverted the same evening after it
+marked an unrelated message read. The test that passed had asserted on a ROOT
+selection, the one case where `current.row()` is correct.
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 2594890..63c192c 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
@@ -130,7 +130,9 @@ taking that too literally.
| 63 | No way to see sent mail, and no filter for it | workflow | M | **done** 2026-08-11; see `specs/2026-08-11-sent-mail-design.md` |
| 64 | The Sync button carries a mailbox icon, not a refresh one | presentation | XS | **done** 2026-08-11 |
| 65 | No full code review and optimization pass | correctness | ? | open, unspecified |
-| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | open; the simple case is RULED OUT, measured 2026-08-14 with the item 36 fixture. Needs a condition nobody has named yet, see the section |
+| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | **done** 2026-08-14, unreleased. Not the blank pane it was filed as: the root rendered the CONVERSATION until the thread had been expanded once, then one message. Now always one message, and the conversation view is removed at the user's request |
+| 87 | Auto mark-read marks a whole thread, including replies never displayed | defect | S | open; measured 2026-08-14. Reachable only after 66 removed the conversation view. Blocked on 88 |
+| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | S | open; found 2026-08-14 by shipping a fix that marked an unrelated message read. Row numbers are per parent in a tree |
| 67 | The placeholder pane counts unread, flagged and inbox, but not sent or drafts | information | XS | **done** 2026-08-11, shipped in 0.15.0 |
| 68 | A forwarded subject gets no `passed` tag | workflow | S | open; no subject rule exists, measured 2026-08-11. Decision needed: display mark (XS) or write the flag (S, syncs out) |
| 69 | `passed` and `replied` read as words where every other state is a glyph | presentation | S | **done** 2026-08-11, inside item 70 |
@@ -286,68 +288,80 @@ recorded.
**Size: `?`, unspecified.** Do not propose a design for this; ask.
-## 66. Selecting a thread root leaves the message pane blank until a reply has been selected
-
-**Observed (user, from the notes):** clicking the main message of a thread shows
-nothing in the right pane. After expanding the thread and selecting a reply,
-clicking the main message again renders it correctly. The user filed a second
-observation as a possible duplicate: clicking the main message shows the whole
-thread with stubs and the last two replies expanded, and after clicking a reply
-and going back to the root, only the root's own message is shown.
-
-**Cause (partly verified, and the two halves are not the same fault).**
-
-The second half is not a defect at all. A thread row renders the conversation and
-a message row renders one message; `onThreadSelected` (`src/mainwindow.cpp:1847`)
-branches on `isMessageRow` for exactly that. What breaks the user's expectation is
-that going back to the root should return to the conversation view, and it does
-not always: `m_currentMessageId` is set by the message branch
-(`src/mainwindow.cpp:1860`) and `onMessageLoaded` refuses to render when it is
-empty (`src/mainwindow.cpp:1924`), so the two paths are ordered by which reply
-lands first, not by which row is current. This is the "a queued load can outlive
-the state that started it" class already recorded in CLAUDE.md.
-
-The first half, a blank pane on the very first click, has a candidate and only a
-candidate: `onThreadSelected` returns without loading anything when the index is
-not yet selected (`src/mainwindow.cpp:1804`), a guard added so that a view taking
-focus does not open and mark-read a message the user never looked at. If a click
-can deliver `currentRowChanged` before the selection model is updated, which
-CLAUDE.md records as this signal's documented behaviour, that guard drops the
-load and `onSelectionChanged` only reloads when the row CHANGED
-(`src/mainwindow.cpp:1721`), which it did not.
-
-**Approach:** do not fix from this reading. The candidate above is exactly the
-kind of plausible cause the project has been burned by twice; instrument the
-running application and watch the order of `currentRowChanged`,
-`selectionChanged` and the worker reply for one first click on a cold view.
-
-**Ruled out 2026-08-14: the simple case does not reproduce.** Item 36 shipped
-the fixture this entry said was missing, and
-`selectingAThreadRootShowsItInTheMessagePane` in `test_mainwindow` now builds a
-real database, runs a real query and selects an unexpanded thread root with a
-real `threadLoaded` arriving. The pane renders. The test is mutation-confirmed
-to fail when `onThreadLoaded()` is disabled, so it is a probe with teeth rather
-than a green that proves nothing.
-
-**What that leaves, and it sharpens this entry's own candidate.** The test uses
-`setCurrentIndex()`, which updates the selection model SYNCHRONOUSLY. A real
-mouse click does not, and the guard at `src/mainwindow.cpp:1804` returns without
-loading when the index is not yet selected. So the negative result is consistent
-with the candidate above rather than against it: it says the defect is in the
-CLICK path, not in the load path. Reproducing it needs `QTest::mouseClick` on
-the viewport at the row's `visualRect`, not a programmatic selection.
-
-Two other conditions remain unexcluded and are cheaper to try than a click
-harness: a thread of more than two messages, and a cold page cache, which item
-74 measures as seconds on this machine and which is exactly the window in which
-an ordering fault would show.
-
-**Constraints:** the mark-read guard at 1804 must survive whatever is done, it is
-item 35b's fix and removing it re-opens marking mail read without the user having
-seen it. A fix that only re-orders the two guards will look correct and will still
-be timing-dependent.
-
-**Size: S**, and a defect rather than an enhancement.
+## 87. Auto mark-read marks a whole thread, including replies never displayed
+
+**Observed (user, 2026-08-14):** "with the first message in a thread selected
+(not expanded), the 2s delay that marks it read applies to the whole thread, so
+all answers are marked read as well."
+
+**Cause (verified in the code).** `markCurrentThreadRead`
+(`src/mainwindow.cpp`) sends `sendThreadTagChange` with the thread id, so
+`unread` is removed from every message in the thread.
+
+**This was coherent until item 66 and is not any more.** While a thread root
+rendered the whole conversation, everything marked read HAD been displayed.
+Item 66 made a root render one message; the thread-wide write stayed.
+
+**Not cosmetic.** `maildir.synchronize_flags` is on, so removing `unread`
+rewrites Maildir filenames and the next sync carries it to the server. Mail the
+user never opened stops being unread everywhere, and nothing here can put it
+back except reading the messages again by hand.
+
+**Approach: blocked on item 88, and that ordering is the lesson.** The obvious
+fix is to send `sendMessageTagChange` for the message on screen. That was
+written, tested, mutation-checked, shipped and reverted the same evening,
+because it exposed item 88: the guards above the write read
+`threadAt(current.row())`, which answers about an unrelated thread when a reply
+row is current, and a fix that narrows the write turns that mismatch into "a
+random message is now read". Fix 88 first.
+
+**Constraints for the second attempt.**
+
+- **Test selecting a REPLY, not only a root.** The reverted work asserted on a
+ root selection, which is the one case where `current.row()` is correct, so a
+ mutation-checked test passed against a bug that corrupted mail.
+- The card must repaint. `sendMessageTagChange` deliberately makes no
+ optimistic model update, since `applyTagChange` is thread-keyed; without a
+ message-scoped equivalent the write lands, the unsynced count rises, and the
+ card stays bold, which the user reported as a separate fault.
+- A thread reads as unread while ANY message does, so the card can only stop
+ looking unread when nothing else in it carries the tag. For an unexpanded
+ thread the per-message tags are not loaded, so the honest answer needs
+ per-message counts in `ThreadSummary` or nothing at all.
+
+**Size: S**, after 88.
+
+## 88. `threadAt(current.row())` answers about the wrong thread for a reply row
+
+**Observed (user, 2026-08-14):** "I clicked on a message and another random
+message was marked read."
+
+**Cause (verified).** `markCurrentThreadRead` calls
+`m_model->threadAt(current.row())`. A `QTreeView` numbers rows PER PARENT, so a
+reply's `row()` indexes its siblings: `threadAt(0)` on the first reply of any
+thread returns the FIRST THREAD IN THE LIST. The guards then compare correct ids
+against the wrong thread and let a write through.
+
+**`CLAUDE.md` already records this trap**, from item 20: "what did NOT survive
+that port is anything keyed on a row NUMBER". This is a surviving instance, not
+a new discovery, which is the uncomfortable part.
+
+**Mostly masked before item 87's attempted fix.** With a thread-wide write the
+mismatch usually resolved to the same thread or to a no-op; scoping the write to
+one message made it visible immediately, as unrelated mail being marked read.
+
+**Approach.** `threadAt(int)` takes a row and cannot be safe here. Every caller
+that has a `QModelIndex` should reach the thread through the index: for a
+message row, `ThreadListModel::threadIdForMessage()` or the parent index; for a
+thread row, `index.row()` is correct. Audit every `threadAt(` and every
+`.row()` in `mainwindow.cpp` rather than fixing this one site, since the same
+shape almost certainly appears elsewhere.
+
+**Consider deleting the row-taking overload** once the callers are converted, so
+the trap cannot be re-entered. That is the change that makes this permanent
+rather than fixed once.
+
+**Size: S** for the one site, **M** if the audit finds several.
## 68. A forwarded subject gets no `passed` tag