aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-28 19:17:06 +0200
committerDanilo M. <danix@danix.xyz>2026-08-28 19:17:06 +0200
commitdb38ca136c45f34f5fa2d342ee180f068073be37 (patch)
treee5ec786f47989bb594fca896802849b365056ddc
parentae74237ca11640ceb68887b2c6d09ecf1befb342 (diff)
downloadqtmaildir-db38ca136c45f34f5fa2d342ee180f068073be37.tar.gz
qtmaildir-db38ca136c45f34f5fa2d342ee180f068073be37.zip
docs: close items 170, 176 and 177
-rw-r--r--CHANGELOG.md88
-rw-r--r--CLAUDE.md243
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md159
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md95
-rw-r--r--docs/superpowers/specs/2026-08-28-thread-row-identity-design.md29
-rw-r--r--translations/qtmaildir_it_IT.ts94
6 files changed, 535 insertions, 173 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 062fa8e..56d98e4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,18 @@ point at which they are stable.
### Added
+- **A dashboard for a conversation.** Selecting a thread of more than one
+ message now shows the conversation itself rather than one of its messages:
+ the subject, how many people wrote and over how many days, the account, the
+ thread's tags in one row, the message and unread counts with a bar showing
+ how much of it you have read, and an activity line reading `first → last`
+ with the busiest stretch named. "Waiting for you" lists the unread messages
+ newest first, capped at five with a `+N more` link that expands the thread
+ in the list beside it. Mark all read, Archive and Delete sit in a strip
+ pinned to the bottom, so they stay reachable on a long conversation.
+ Selecting a conversation does NOT mark anything read, since no single
+ message is on display to mark.
+
- **The unsynced-changes count opens.** Clicking `N unsynced changes` in the
status bar lists what those changes are: each message with the actions
waiting on it beneath it, and a whole-thread action shown as one entry with
@@ -69,11 +81,40 @@ point at which they are stable.
same whichever direction it would take; it is now "Mark as read" on unread
mail and "Mark as unread" on read mail, and it is hidden entirely when the
selection holds both, where no label would be true.
-- **Marking a whole thread read or unread is now two entries**, both under
- Whole thread, replacing the single toggle. A thread's unread state is the
- union over its messages, so a thread with one unread reply always answered
- "unread" and the toggle could only ever mark it read. Neither entry carries
- a shortcut, and `Ctrl+Alt+U` is now unbound.
+- **A row in the list is now either a conversation or a message, and it says
+ which.** A thread with more than one message is the CONVERSATION: selecting
+ it shows a dashboard of the whole thread rather than one message pulled out
+ of it, and every action on it covers every message in it. A thread of one
+ message is unchanged, opening its message on a single click exactly as
+ before. Replies inside an expanded thread are still individual messages and
+ still act on themselves alone.
+- **The "Whole thread" submenu is gone, and the ordinary actions name their
+ scope.** The row already decides what an action covers, so a second set of
+ entries was a second answer to a settled question. On a conversation the
+ entries read "Delete thread", "Archive thread", "Mark thread as read",
+ "Mark thread as spam", "Important thread" and "Restore thread from trash";
+ on a message they read as they always did. The five `*_thread` action names
+ are removed, see Upgrading.
+- **Delete and Archive are conversation-level only.** They are absent, not
+ disabled, on a reply inside an expanded thread: removing one reply from a
+ conversation is not something the interface offers. A thread of one message
+ keeps both, because there deleting the message and deleting the conversation
+ are the same act. Forward and Save disappear on a conversation row for the
+ same reason, and Reply becomes one entry, "Reply to this thread", which
+ replies to all and quotes nothing.
+- **A mixed conversation reads as unread.** Any conversation holding an unread
+ message offers "Mark thread as read" and marks every message read; only a
+ fully read selection offers "Mark thread as unread". The action is no longer
+ hidden on a selection that disagrees with itself: with the absolute entries
+ gone that would have left no way to act at all, and two presses now reach
+ either state from anywhere. `Ctrl+Alt+U` is unbound.
+- **A conversation stays in a view while any of its messages still match it.**
+ Reading one message of five no longer takes the conversation out of Unread.
+ A row is never removed while it is the current one, so the automatic
+ mark-read cannot pull a row out from under you, and a write you asked for
+ removes the row at once while an automatic one waits until you move on.
+ Reading the last unread message of a long conversation is the one case that
+ still waits for the next query or sync.
- **Delete and Restore appear only where they apply.** Delete is hidden on
mail already in the trash, where it reported success and did nothing, and
Restore is hidden on mail that was never deleted.
@@ -96,6 +137,24 @@ point at which they are stable.
### Fixed
+- **Undo no longer rewrites messages the action never touched.** Undoing a
+ thread-scoped action inverted its tags while keeping the whole thread as its
+ scope, so undoing "mark thread read" on a conversation of 44 messages that
+ held 2 unread added `unread` to all 44 and left 43 of them unread. Because
+ `maildir.synchronize_flags` is on, that rewrote the files on disk and the
+ next sync would have carried it to the mail server. An action now records
+ which messages its write actually changed, and its undo covers only those; a
+ write that changed nothing pushes no undo entry at all. The same defect was
+ present on a multi-row message selection and is fixed with it.
+- **A row now leaves a view as soon as it stops belonging to it.** Marking a
+ message read left it sitting in the Unread view, un-flagging left it in
+ Flagged, and removing `inbox` by hand left it in the Inbox, each correcting
+ itself only at the next query or sync. Every tag write is checked now, and
+ the reverse case too: a write that puts the view's tag back refreshes the
+ list, so an undone mark-read is visible again in the view it was undone in.
+ Views defined by a folder rather than a tag, and hand-written queries, are
+ deliberately left alone.
+
- **Forwarding an HTML message kept its formatting.** A forward carried only
the plain-text version of the original, so tables, emphasis and layout were
flattened, and a message with no plain-text part at all (about one in eleven
@@ -150,6 +209,25 @@ point at which they are stable.
hook's sent-mail carve-out matched the sent copy and stripped the tag from
both.
+### Upgrading
+
+**The five whole-thread action names are removed.** A `[keys]` section that
+binds any of them names an action that no longer exists, and qtmaildir will
+report it as unknown at startup. Delete the binding: the ordinary action now
+does whatever the selected row means, so one binding covers both cases.
+
+| Removed | Replaced by |
+|---|---|
+| `archive_thread` | `archive` |
+| `delete_thread` | `delete` |
+| `spam_thread` | `spam` |
+| `flag_thread` | `flag` |
+| `mark_thread_read` / `mark_thread_unread` | `toggle_unread` |
+
+Their default bindings go with them: `Ctrl+Alt+E`, `Ctrl+Alt+D`, `Ctrl+Alt+S`
+and `Ctrl+Alt+I` are unbound, and so is `Ctrl+Alt+U`, which the two
+mark-thread entries had already given up. Nothing needs adding in their place.
+
## [0.27.0] - 2026-08-25
qtmaildir can write mail. A composer, markdown bodies sent as plain text or
diff --git a/CLAUDE.md b/CLAUDE.md
index e506891..d698295 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -62,7 +62,9 @@ MainWindow NotmuchWorker
│ saved-query QPushButtons
├ ThreadListView (QTreeView) ── ThreadListModel (QAbstractItemModel)
│ ONE column of cards; CardDelegate paints each whole, from CardLayout
- └ MessageView (header QLabel, QWebEngineView, attachment bar, TagStrip)
+ └ MessageView (a QStackedWidget over two pages)
+ ├ the message page (header QLabel, QWebEngineView, attachment bar, TagStrip)
+ └ ThreadDashboard (a conversation, from a ThreadDigest; its own TagStrip)
ComposeWindow (its own top-level window, one per message being written)
├ MarkdownFormat (namespace: what the formatting buttons do to a selection)
@@ -85,11 +87,11 @@ listed `QueryBar`, `SavedQueryBar`, `HeaderWidget` and `AttachmentBar`; none of
those types have ever existed, and looking for them wastes a search. The widget
classes that do exist are `MessageView`, `ThreadListView`, `TagStrip`,
`TagDialog`, `MessageDetailsDialog`, `PendingChangesDialog`,
-`RowStyleDelegate`, `CardDelegate`, `ComposeWindow`, `SendDialog` and
-`BusyIndicator`; `TagChip` is a namespace of
+`RowStyleDelegate`, `CardDelegate`, `ComposeWindow`, `SendDialog`,
+`ThreadDashboard` and `BusyIndicator`; `TagChip` is a namespace of
painting helpers, not a widget, `SearchTerm` is a namespace of query builders,
-and `ThreadCidMap`, `CardLayout`, `SearchOffer`, `HeaderRow` and
-`PendingChangeRow` are structs.
+and `ThreadCidMap`, `CardLayout`, `SearchOffer`, `HeaderRow`, `ThreadDigest`
+and `PendingChangeRow` are structs.
`SubjectDelegate` existed until item 53 and is gone.
**The compose units are mostly NAMESPACES, and the same warning applies to
@@ -240,6 +242,35 @@ open handle per process, so that close-first ordering is required, not stylistic
mutation pushes its inverse (`TagChange::inverted()`) onto a `QUndoStack`. Do not add
confirmation dialogs for tag mutations.
+**An undo covers what the write CHANGED, never what it asked for, and getting
+this wrong rewrote real mail.** Item 176. `ThreadTagCommand::undo()` inverted
+the tags and kept the THREAD as its scope, so undoing "mark thread read" on a
+conversation of 44 messages that held 2 unread added `unread` to all 44 and left
+43 unread. `maildir.synchronize_flags` is on, so the Maildir filenames were
+rewritten and the next sync would have carried it to the mail server; it was
+repaired by hand. The undo stack is this application's substitute for a
+confirmation dialog, so an undo that damages state is worse than the dialog it
+replaces.
+
+`NotmuchWorker::applyTags()` is the only place that can know: it reads each
+message's tags before writing and reports only the ids whose tags actually
+moved. A `TagCommand` base carries that effective set for both
+`ThreadTagCommand` and `MessageTagCommand`, which had the same shape and the
+same defect on a multi-row selection. Two consequences that look like details
+and are not:
+
+- **`tagsApplied` does NOT fire when the effective list is empty.** Emitting an
+ empty change would push an undo entry whose inverse adds a tag no message ever
+ carried, which is the same bug one step later.
+- **`sendThreadTagChange` takes an `onlyMessageIds` parameter** so it keeps its
+ thread-scoped REPAINT while restricting the WRITE. Those two scopes differ on
+ purpose: the conversation card is what changed on screen, and a subset of its
+ messages is what changed on disk. Do not collapse them.
+
+A test for this needs a thread whose messages DISAGREE about the tag, for the
+same reason item 87 records: two messages in the same state answer identically
+whichever way the code resolves them.
+
**There is exactly ONE exception, and its shape is the rule's own logic rather
than a hole in it.** `empty_trash` (item 118) destroys files and index entries,
so it has no inverse to push, and the protection the rule actually provides —
@@ -615,51 +646,29 @@ probe showed `QMetaType::fromName("QList<int>")` invalid while
that drives it across a real thread is what says it works, and what fails if
it stops.
-**`ThreadSummary::tags` is notmuch's UNION over the thread, and a card that
-stands for one message must not draw it.** A four-message thread whose third
-message is `signed` reads as signed, so the root card and the message pane both
-claimed a tag the displayed message did not have (item 110). `MessageRef`
-carries the message's own tags and arrives on every load;
-`ThreadListModel::setRootMessageTags()` records them on `ThreadNode::first`, and
-a thread row's `data()` substitutes `first.tags` for the summary's when that
-node exists. Only the TAGS are substituted: the subject, authors, date and reply
-count describe the thread and are correct. The summary itself is never
-rewritten, because the thread-scoped actions and the query read it.
-
-That was also why a root card could not repaint: with no per-message tags, a
-message-scoped write had nothing to change. `applyMessageTagChange` keeps the
+**`ThreadSummary::tags` is notmuch's UNION over the thread, and a conversation
+row draws exactly that.** This REVERSES items 110 and 111, deliberately, under
+item 177 on 2026-08-28, and the reversal is the point rather than a regression:
+both of those solved the problem of a card standing for one message while
+carrying its thread's tags, and item 177 removed the premise instead. A row
+with replies IS the conversation, so the union is the honest answer and needs
+no correction. A row with `totalCount == 1` has a union that IS its message, so
+the union is honest there too. There is no case left where a card claims a tag
+its subject does not have.
+
+What went with them: `setRootMessageTags()`, `ThreadSummary::firstMessageTags`,
+the two-tier `PillTagsRole` / `PillOwnCountRole` split, `CardLayout::siblingFont()`
+and `CardDelegate::mutedChipColour()`. The chips are one tier, in one font, in
+one colour treatment. **The user was asked and confirmed they are happy to lose
+the second tier**, so do not restore it on the reasoning that item 111 recorded:
+that reasoning was sound for a card that stood for one message, and no card does
+any more. Restoring it would reintroduce a per-message identity on a row that
+has none, which is exactly the ambiguity item 177 exists to remove.
+
+The one thing to keep from that history: `applyMessageTagChange` keeps the
summary in step only when `totalCount <= 1`, where the union IS the message.
-
-**The card shows BOTH tiers, and that is item 111 rather than a leftover.**
-`PillTagsRole` returns the displayed message's tags first and the thread's other
-tags after; `PillOwnCountRole` is the boundary the delegate switches fonts at.
-The second tier is drawn in `CardLayout::siblingFont()` and
-`CardDelegate::mutedChipColour()`.
-
-**The split comes from the QUERY, not from the message load**, and that
-distinction was worth a whole round trip. `ThreadSummary::firstMessageTags` is
-read by the same worker walk that finds `firstMessageId`, so an UNOPENED row
-already knows which tags are its own. Deriving it from the load instead left
-every unopened row drawing one tier and correcting itself on selection, which is
-most of the list and is exactly the "chip changed when I clicked" the feature
-exists to remove. `nodeFor()` seeds the node on arrival; `reconcile()` must
-refresh it AND compare it, since a survivor keeps its node and a sync can move
-the root's tags while the thread's union stands still.
-
-**A size step must be a FRACTION, not a subtraction, and the padding has to
-follow it.** One point off a 14pt desktop font is a 7% step and reads as the
-same size; the user reported exactly that. `CardLayout::siblingFont()` is 0.70
-of the card font. `TagChip::kPaddingX` is a fixed 9px a side, so an unscaled
-sibling chip is 18px of padding around ~30px of text and stays wide while its
-letters shrink: `TagChip::sizeFor()` takes a scale, and
-`CardDelegate::chipSize()` is where the tier chooses it. Assert on ratios rather
-than sizes, so the test is about the distinction and not the constant.
-
-Muting is **saturation only**. Hue stays so the tag is recognisable; lightness
-stays so `TagColors::textColourOn()` keeps its choice and the chip cannot become
-unreadable. Do not blend toward the background here: `accentLineColour()`
-records what that costs on a dark theme, and a chip is worse because its fill
-carries text.
+On a longer thread it deliberately leaves the summary alone, which is why the
+membership rule below has a documented lag.
**`TagStrip::visibleTags()` measures the LAYOUT, not the data.** It is one row
that collapses the overflow into a trailing "+N" chip, and an unshown window
@@ -668,6 +677,12 @@ under the offscreen platform has no width, so nearly everything lands in
many tags happened to fit; two shipped that way before it was noticed. Assert on
`visibleTags() + hiddenTags()`.
+**The message pane holds TWO `TagStrip`s since item 177**, one in the message
+view and one in the dashboard, so an unqualified `findChild<TagStrip *>()`
+returns whichever the object tree happens to yield first and a test written that
+way asserts about the wrong widget. Both carry object names:
+`messageTagStrip` and `dashboardTagStrip`. Find by name.
+
**A message-scoped write repaints the MESSAGE's row, never the thread's.**
`ThreadListModel::applyMessageTagChange()` is the counterpart to
`applyTagChange()` and exists because there was no optimistic update at all for
@@ -677,22 +692,75 @@ should repaint, so Delete and Toggle unread on a reply moved the pending count
and changed nothing on screen (item 105). The thread card deliberately stays
put; one deleted reply does not doom the conversation.
-**A thread ROW means the one message its card displays, not the conversation.**
-Item 108, 2026-08-16. `ThreadListModel::messageScopeFor()` is what the ordinary
-tag actions resolve through; `scopeFor()` still returns whole threads and is
-what the five `*_thread` actions use. A thread row's message is
-`ThreadSummary::firstMessageId`, carried from the query, so no expansion is
-needed; in the Sent view that is the first MATCHED message, which is right for
-the same reason it is right on the card. A row with no id contributes NOTHING
-rather than falling back to its thread: that fallback is the silent escalation
-this removed.
-
-The automatic mark-read follows the same rule (item 87): `m_markReadMessageId`,
-armed for a reply as well as a root. One approximation is deliberate and
-documented at the call site: a thread row arms from `ThreadSummary::isUnread()`,
-a union over the conversation, so it can arm for a thread whose displayed
-message is already read. The write is still scoped to that message, so the cost
-is a no-op rather than a wrong write.
+**A row is either a CONVERSATION or a MESSAGE, and one question decides which.**
+Item 177, 2026-08-28. This REVERSES item 108, which had made a thread row mean
+the one message its card displayed. That was a coherent answer to a real
+ambiguity and it did not hold: a row that means a message needs a second set of
+actions to reach its thread, and the two sets then disagree about what the
+gesture the user just made was for. Item 177 answers it at the row instead.
+
+`ThreadListModel::isConversationRow()` is the single predicate. A summary with
+replies is the conversation; a summary with `totalCount == 1` is its message and
+behaves exactly as it always has, opening on one click; a reply row inside an
+expanded thread is its own message. `scopeForSelection()` is the ONE resolver,
+replacing the `scopeFor()` / `messageScopeFor()` pair, because a pair made the
+CALLER choose and that choice was the ambiguity. It resolves per row, so a mixed
+selection carries both kinds.
+
+**Do not restore `messageScopeFor()`, the five `*_thread` action names or the
+"Whole thread" submenu.** They are removed, with an `### Upgrading` note in the
+changelog. A second set of actions is a second answer to a settled question, and
+`ThreadSummary::firstMessageId` no longer decides a thread row's scope at all
+(it survives for other callers). A new action asks `isConversationRow()`; it
+does not add a `_thread` twin.
+
+**Delete and Archive are ABSENT on a reply row, not disabled**, at the user's
+own decision: removing one reply from a conversation is not offered. A thread of
+one keeps them, since there the two acts are the same, so the test in
+`refreshActionLabels()` is "is this a reply", which only a message row can be.
+Forward and Save disappear on a conversation row, and Reply is one entry,
+"Reply to this thread", which is reply-all and quotes nothing.
+
+**The unread toggle is a CATCH-ALL, and that reverses item 112's hiding rule.**
+Item 112 hid the toggle whenever the selection disagreed with itself, on the
+correct reasoning that a union is not a state. That was affordable only because
+the `Whole thread` submenu carried absolute entries beside it; with the submenu
+gone, hiding leaves no way to act at all. So: ANY unread message, a mixed
+conversation included, reads "Mark thread as read" and marks every message read;
+only a fully read selection reads "Mark thread as unread". Two presses reach
+either state from anywhere. **The write direction must move with the label**, or
+a mixed conversation gets marked unread under a label promising read.
+
+The automatic mark-read still follows item 87's rule for a message row
+(`m_markReadMessageId`, scoped to the displayed message). It is NOT armed for a
+conversation row, because a conversation puts no single message on display and
+there is nothing to mark.
+
+**Membership is the union, with no exceptions.** A thread belongs to a view
+while ANY of its messages match it, so reading one message of five does not take
+the conversation out of Unread. `MainWindow::syncViewMembership()` is the guard
+and is called from all three funnels (message, thread, move). Three properties
+of it are decisions rather than implementation:
+
+- A row is **never evicted while it is current.** The automatic mark-read fires
+ two seconds after selection, so evicting would take the row out from under the
+ user mid-read.
+- A write the user **asked for** evicts at once; an **automatic** one defers
+ until the selection moves (`m_deferredEvictions`). The deferral keys on the
+ write being automatic, so a test that calls the send path directly exercises
+ the opposite branch: drive it through the mark-read TIMER.
+- The inverse case **refreshes rather than inserting.** The model holds no
+ summary for a thread the query never returned, so a row that starts matching
+ cannot be inserted optimistically. Without this an undone mark-read stayed
+ invisible in the view it was undone in.
+
+**One lag is deliberate and is not a wrong answer.** Reading the LAST unread
+message of a long conversation does not evict it immediately, because
+`applyMessageTagChange` leaves a long thread's summary alone (see the union rule
+above), so the union the model holds is stale and judging on it would be wrong
+in BOTH directions. `threadCountFor()` is the question a message-scoped write
+asks before judging: a thread of one has a union that moved, a longer one does
+not. The row leaves at the next query or sync.
**Adding an action is FIVE places, and three of them are enforced by tests that
fail in confusing ways.** `KeyMap::knownActions()` (a `Q_ASSERT` in the
@@ -702,9 +770,12 @@ since item 132: a shortcut is a chosen subset, not a requirement, so an action
nobody would press a chord for simply gets no entry and the shortcut reference
prints it as `(unbound)`), the icon table (every action must carry one), and
a MENU. The no-duplicate-icons rule is narrowed to actions that can reach the
-toolbar, by a named exception list; the five thread actions share their twins'
-icons because a submenu entry always carries text, and the test asserts none of
-them is on the toolbar so the exemption cannot be abused.
+toolbar, by a named exception list, and the test asserts none of the exempt
+actions is on the toolbar so the exemption cannot be abused. The list held six
+whole-thread actions until item 177 deleted them; it is down to
+`reply_no_quote`, which is a menu entry that always carries its text. Note it is
+named for the PROPERTY that earns the exemption, not for the tier that first
+needed one, which is why it survived that tier's deletion unchanged.
**The menu was the fifth place, and this document said four until item 103.**
Nothing enforced it, so `restore` shipped on the trash branch reachable by
@@ -802,17 +873,41 @@ measuring nothing:
children are populated by the expansion. `hasChildren()` is the pre-expansion
question and falls back to `summary.totalCount > 1`. An assertion on
`rowCount` fails against correct code.
-- **A `ThreadSummary` fixture needs `firstMessageId`.** Since item 108 an
- ordinary tag action resolves a thread row to that id, so a summary without one
- names no message and every action on it silently does nothing. Ten tests
- failed this way at once, all reporting "the action did not happen", which
- reads as a defect in the action rather than a gap in the fixture.
- `makeThread()` sets it; a hand-built summary must too.
+- **A `ThreadSummary` fixture needs `totalCount` set to what it MEANS**, since
+ item 177 reads exactly that to decide whether a row is a conversation or a
+ message. A summary left at the default is a message row, so a test meaning to
+ exercise a conversation quietly exercises the other branch and passes for the
+ wrong reason. `makeThread()` sets it; a hand-built summary must too.
+ `firstMessageId` still matters for a `totalCount == 1` row, which resolves to
+ its message: without one it names nothing and every action on it silently does
+ nothing. Ten tests failed that way at once under item 108, all reporting "the
+ action did not happen", which reads as a defect in the action rather than a
+ gap in the fixture.
- **`currentThreadId()` reports INTENT, not content.** It is assigned
synchronously in the selection handler before any worker round-trip, so a test
asserting on it passes with `onThreadLoaded()` disabled entirely, measured.
`MessageView::showingPlaceholder()` is what the user sees; assert the pane is
blank BEFORE the gesture so the check after it means something.
+- **A `QStackedWidget` takes the LARGEST minimum width of all its pages**, so a
+ page that is not showing can inflate the pane's minimum and squeeze the widget
+ beside it. The dashboard raised the message pane's minimum to 395px over
+ `MainWindow`'s own 300px floor, and the thread list lost the difference. Fixed
+ with `setMinimumWidth(0)` and `QSizePolicy::Ignored` on the stack. An existing
+ resize test caught it, which is the argument for keeping resize tests that
+ look like they assert nothing interesting.
+
+**Five tests changed under item 177 and must not be restored as they were.**
+Two are RETIRED because the behaviour they asserted is the behaviour that was
+reversed: `selectingARootCardKeepsItsThreadForMarkRead` (replaced by
+`selectingAConversationArmsNoMarkRead`, since a conversation arms no mark-read
+at all) and `anUnexpandedRootRendersOneMessageNotTheConversation` (replaced by
+`anUnexpandedRootShowsTheDashboardLikeAnExpandedOne`, which keeps item 66's real
+value, consistency across the expansion boundary, and inverts only which way
+that consistency runs). Three are RETARGETED to `totalCount == 1` rows, where
+their assertions still hold and still matter:
+`autoMarkReadTouchesOnlyTheMessageOnDisplay` (item 87's data-safety property,
+still asserted), `theStaleNoticeKeepsTheMessageOfAThreadRootToo` and
+`aLoadedMessageCorrectsTheStripFromTheThreadsUnion`.
**A queued load can outlive the state that started it.** `loadThread` crosses to the worker
on a queued connection, so its reply lands after whatever the UI did in the meantime. The
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 81705a2..f062106 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
@@ -9090,3 +9090,162 @@ forwards correctly with the strip box both checked and unchecked. The
recipient sees the original's formatting either way; with the box checked the
remote images are gone and the layout survives without them.
+## 170. A row that stops matching the view only leaves it on the Delete path
+
+**Observed (user, from the notes):** "should we refactor the list UI to be
+responsive so changes are applied immediately instead of waiting for a view
+change to repaint?"
+
+**Cause (verified in the code, 2026-08-26).** Two different properties were
+being called "responsive", and only one of them was built.
+
+The optimistic **repaint** is universal. `ThreadListModel::applyTagChange()`
+covers a thread-scoped write, `applyMessageTagChange()` a message-scoped one
+(items 105 to 111), and `revertPendingTagChange()` undoes either if the write
+is rejected. A chip, a bold row and a dimmed row all move the moment the user
+acts.
+
+The optimistic **membership** is not. `ThreadListModel::removeThreadsWithoutTag()`
+has exactly ONE caller, in `trashMessages()`, added last session because Delete
+strips `inbox` and a deleted message sat in the Inbox view across restarts. The
+ordinary tag path never calls it: neither `sendMessageTagChange()` nor
+`sendThreadTagChange()` asks whether the row still belongs in the view.
+
+So in the Unread view, marking a message read repaints the row and leaves it in
+a list defined by `tag:unread`, which it no longer matches. Un-flagging in the
+Flagged view is the same, and so is removing `inbox` by hand from the Inbox
+view. It corrects itself at the next query or sync, which is exactly the "waits
+for a view change" the note describes.
+
+**Approach.** Not a refactor. `viewFilterTag()` already resolves the view's own
+tag from the query, and `removeThreadsWithoutTag()` already does the removal.
+The gap is that the guard sits in `trashMessages()` rather than at the funnel
+every tag write passes. Move it, or call it from both send paths.
+
+**Constraints.**
+
+- The guard's existing reasoning is what makes this safe and must be kept: only
+ a plain `tag:<x>` view has a membership one tag decides. A path query (Trash,
+ Sent, Drafts) is unaffected by a tag going away, and a hand-typed query cannot
+ be reasoned about. Both are left alone. Without that, marking read in an `id:`
+ view would empty the list.
+- A row leaving is not revertible by `revertPendingTagChange()`, which repaints
+ rather than reinserts. A REJECTED write would leave the row gone until the
+ next query. The move path already carries that exposure; check whether it is
+ acceptable at the tag path's much higher frequency, or make the removal wait
+ for confirmation there.
+- The inverse case is deliberately out of scope: a row that starts matching
+ cannot be inserted optimistically, since the model has no summary for a
+ thread the query never returned.
+- Undo goes back through the same funnel, so a removal must not make an undone
+ mark-read invisible in the view it was undone in.
+
+**Resolved 2026-08-28**, on `thread-row-identity`, released unreleased.
+
+`MainWindow::syncViewMembership()` is the guard. It was moved off the move
+path, where `trashMessages()` was its only caller, and is now called from all
+three funnels: the message write, the thread write and the move. The existing
+reasoning under Constraints is kept unchanged, so only a plain `tag:<x>` view
+is acted on; a path query and a hand-typed query are still left alone.
+
+Three things the section did not anticipate:
+
+- **The inverse case is built after all.** A write that puts the view's tag
+ BACK refreshes the list. The model still cannot insert a row for a thread the
+ query never returned, which is why it is a refresh rather than an insertion,
+ but leaving it out made an undone mark-read invisible in the view it was
+ undone in, which is worse than the query cost.
+- **A row is never evicted while it is current**, and an AUTOMATIC write defers
+ its eviction until the selection moves. The automatic mark-read fires two
+ seconds after selection, so evicting on it takes the row out from under the
+ user mid-read. A write the user asked for evicts at once.
+- **A second defect was found while testing it.**
+ `ThreadListModel::applyTagChange()` never updated the ROOT message's own
+ tags, which a thread row's card drew in preference to the summary since item
+ 110, so an archived thread both kept drawing `inbox` and was judged to still
+ match. Item 177 has since removed that per-message tier entirely, so the
+ card and the judgement now read the same union.
+
+One lag is documented rather than fixed, in the spec and in CLAUDE.md: reading
+the LAST unread message of a long conversation does not evict it immediately,
+because `applyMessageTagChange` deliberately leaves a long thread's summary
+alone and judging on a stale union would be wrong in both directions. The row
+leaves at the next query or sync.
+
+## 176. Undoing a thread-scoped action applies its inverse to messages it never changed
+
+**Observed (user, 2026-08-28):** found while hand-testing item 170. A
+thread-scoped `Mark thread read`, then Ctrl+Z, left almost the whole
+conversation unread rather than restoring the two messages that had been.
+
+**Cause (measured, not read).** `ThreadTagCommand::undo()`
+(`src/mainwindow.h`) sends `sendThreadTagChange(m_threadIds, m_remove, m_add,
+...)`: the tags are inverted and the SCOPE is not. `applyTags()` is a blind
+add/remove over whatever ids the thread resolves to, so the inverse of
+"remove `unread` from 44 messages" is "add `unread` to 44 messages",
+regardless of which of them carried it.
+
+Measured on the live index: thread of 44 messages, 2 unread. Mark thread read
+resolved 44 and removed the tag; the undo resolved 44 and added it, leaving
+43 unread. The 42 that were read before the user touched anything were
+rewritten. `maildir.synchronize_flags` is on, so the Maildir filenames were
+rewritten too and the next sync would have carried it to the server.
+
+**Approach (not decided).** The command has to record what the write actually
+CHANGED, not what it asked for. `applyTags()` is the only place that knows:
+it holds each message open and can report the ids whose tags actually moved.
+That is a worker change (`tagsApplied` carrying the effective set) plus a
+command that stores it.
+
+**Constraints.**
+- The undo stack is this application's substitute for confirmation dialogs
+ (CLAUDE.md), so an undo that damages state is worse than the dialog it
+ replaces.
+- `MessageTagCommand` has the same shape. It is harmless on a single message,
+ where asked and changed agree, and has the same defect on a multi-row
+ selection.
+- The five `*_thread` actions are the ones that resolve to a large id set, so
+ they carry almost all of the exposure.
+- A test needs a thread whose messages DISAGREE about the tag. Two messages in
+ the same state answer identically whichever way the code resolves them,
+ which is the trap CLAUDE.md already records for item 87.
+
+**Resolved 2026-08-28**, on `thread-row-identity`, unreleased.
+
+The Approach was right about where the knowledge lives.
+`NotmuchWorker::applyTags()` now reads each message's tags before writing and
+reports back only the ids whose tags actually MOVED. A `TagCommand` base
+carries that effective set, and both `ThreadTagCommand` and
+`MessageTagCommand` derive from it, so the multi-row message case named under
+Constraints is fixed by the same change rather than left behind.
+
+Two details that are decisions rather than implementation:
+
+- **`tagsApplied` does not fire when the effective list is empty.** Emitting an
+ empty change would push an undo entry whose inverse adds a tag no message
+ ever carried, which is this same defect one step later.
+- **`sendThreadTagChange` takes an `onlyMessageIds` parameter** so it keeps the
+ THREAD as its repaint scope while restricting the WRITE to that set. The card
+ that changed on screen and the messages that changed on disk are genuinely
+ different sets, and collapsing them would either under-repaint or over-write.
+
+**The spec's own order of work was wrong about this item**, and the correction
+is worth keeping because the wrong version reads as sound. It said item 177
+would make a thread action's undo "honestly what the user asked for", leaving
+only the multi-row message case exposed. An undo is not the inverse of a SCOPE,
+it is the inverse of an EFFECT: the user asking to mark a whole conversation
+read does not make it honest to mark the whole conversation unread afterwards,
+because most of it was already read before they touched anything. Item 177
+changed nothing about the exposure except to make the largest case reachable by
+the ordinary gesture rather than by a submenu entry. Corrected in
+`specs/2026-08-28-thread-row-identity-design.md` rather than deleted from it.
+
+**Evidence.** Measured on the user's live index before the fix: a conversation
+of 44 messages holding 2 unread was marked read, then undone, and came back
+with 43 unread. The 42 that had been read before the user touched anything were
+rewritten. `maildir.synchronize_flags` is on, so the Maildir filenames were
+rewritten too and the next sync would have carried it to the mail server. The
+mail was repaired by hand the same day. The regression test builds a thread
+whose messages DISAGREE about the tag, since two messages in the same state
+answer identically whichever way the code resolves them, which is the trap item
+87 already records.
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 7e7c040..5b84b15 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
@@ -241,14 +241,15 @@ taking that too literally.
| 165 | A draft gets a new Message-ID on every autosave | enhancement | ? | open, 2026-08-25, found while hand-testing 163 and 164. `MessageBuilder::build()` generates an id unconditionally and every autosave calls it, so each revision is a distinct MESSAGE to notmuch and to the server rather than a new version of one. Invisible while the file is replaced correctly, which item 163's fix restores; it is what turned that fork into two messages rather than one duplicated file. Needs a DECISION on what a draft's identity is before any code: a stable id reused at send, a stable id discarded at send, or the status quo. Neither `ComposeContext` nor `OutgoingMessage` has a field to carry an id, so it is not a changed call site |
| 166 | Mail you send to your own other account loses `inbox` | defect | S | **done 2026-08-25**, unreleased. `sent_only()` keeps a message only when EVERY file is inside a sent folder, which is what the carve-out's docstring already claimed. No query can express it, measured; the root comes from `database.mail_root`, with a split-index fixture the ordinary layout cannot provide. Verified read-only against the live index: 780 of 807 still stripped, 27 spared, no arrival affected |
| 167 | No way to tell one build of an unreleased version from another | enhancement | XS | **done 2026-08-25**, unreleased. The user chose a counter over a git description: `QTMAILDIR_BUILD_NUMBER`, a cmake option ON by default, increments a counter in the BUILD directory on every build and writes `buildnumber.h`. `QTMAILDIR_VERSION_DISPLAY` carries it; `QTMAILDIR_VERSION` stays clean and is what the window title, `applicationVersion` and the release procedure use |
-| 168 | Delete is offered on mail already in the trash, and does nothing | defect | S | **done 2026-08-25**, unreleased. Delete is hidden when every selected row is already in its account's trash, Restore when none is, both keyed on the PATH rather than the `deleted` tag. Delete also drops `unread` now, in the same TagChange so one undo returns the folder and the tag together |
+| 168 | Delete is offered on mail already in the trash, and does nothing | defect | S | **done 2026-08-25**, unreleased. Delete is hidden when every selected row is already in its account's trash, Restore when none is, both keyed on the PATH rather than the `deleted` tag. Delete also drops `unread` now, in the same TagChange so one undo returns the folder and the tag together . **Its rule is now incomplete, see item 178** (2026-08-28): the check still judges a row on `firstMessagePath`, which item 177 stopped being a conversation row's identity, so a conversation whose messages disagree about the trash answers on one of them |
| 169 | A card shows the account only as a bar, with no fade and no avatar | presentation | M | **done** 2026-08-26, unreleased, on `card-avatars`, merged fast-forward. Both halves: a `QLinearGradient` from the account colour to the pane's base across the card, and a squircle avatar with initials, given a rect in `CardLayout` so the geometry is asserted without a painter. **Hand-testing found four defects**, all fixed in 9ae43f9: `Avatar::initialsFor()` normalises the display name first (drops the angle-addr, takes the first comma-separated author, unwraps quotes, treats a bare address as no name, requires a word to carry a letter or digit); the two-tone gradient axis spans the DIAMETER rather than a radius, which was letting one hue fill the whole face; the account fade runs right to left, anchored opaque at the card's right edge; and a flat view hashes `ThreadSummary::firstMessageRecipient` rather than the user's own address. The vCard half stays blocked on item 72 |
| 170 | A row that stops matching the view only leaves it on the Delete path | defect | S | **done 2026-08-28**, unreleased. `MainWindow::syncViewMembership()` is the guard, moved off the move path and called from all three funnels (message, thread, move). It does the INVERSE too: a write that adds the view's tag back refreshes, since the model cannot insert a row for a thread the query never returned, and without it an undone mark-read stayed invisible in the view it was undone in. Found a second defect while testing: `ThreadListModel::applyTagChange()` never updated the ROOT message's own tags, which a thread row's card draws in preference to the summary since item 110, so an archived thread both kept drawing `inbox` and was judged to still match |
| 171 | A forwarded HTML message reaches the recipient as plain text | defect | M | **done** 2026-08-27, unreleased. Design in `specs/2026-08-27-forward-html-design.md`. The forward sends ONE part chosen by the Send-as-HTML toggle, with the original shown in a read-only pane beside the editor, and remote content stripped by default with a per-forward opt-out. Hand-tested 2026-08-28. Four parts: `HtmlSanitiser` (an ALLOW-LIST, unlike `namespaceCids()`, because a missed strip is a beacon where a missed rewrite is a broken image), a text fallback for the ~9% of mail with no plain part, the MIME nesting, and the composer control |
| 172 | A draft this application writes is tagged `unread` | defect | XS | **done** 2026-08-27, unreleased. `DraftStore::write()` was called with `"D"`, and `maildir.synchronize_flags` makes notmuch tag anything without `S` as `unread`. Self-healing on the next sync of that folder, which is what made it look intermittent |
| 173 | The composer is a plain-text editor, not WYSIWYG | v2 | L | open, 2026-08-27, **asked for by the user** while hand-testing 171. This is a GUI mail client and should edit rich text the way one does: the forwarded original, and the user's own formatting, visible and editable in place. Supersedes the preview 171 shipped as a middle ground, and **subsumes item 133** (markdown syntax highlighting), which is the same want answered cheaply. See the entry: the draft format and the markdown-as-source-of-truth model both change |
-| 176 | Undoing a thread-scoped action applies its inverse to messages it never changed | defect | S | open, 2026-08-28, found by hand while testing 170, **data-affecting and live**. `ThreadTagCommand::undo()` inverts the tags and keeps the THREAD as scope, so undoing `Mark thread read` adds `unread` to every message in the conversation rather than to the ones the write removed it from. Measured on the user's own mail: a 44-message thread had 2 unread, was marked read, and the undo left 43 unread. `maildir.synchronize_flags` is on, so it rewrote the files and would have reached the server. Repaired by hand the same day. `MessageTagCommand` has the same shape on a multi-message scope |
-| 177 | A thread row means both a message and a conversation, and neither consistently | design | L | open, 2026-08-28, **specified**: `specs/2026-08-28-thread-row-identity-design.md`, read that rather than this row. A thread row becomes the CONVERSATION: it carries no first message, shows a dashboard rather than a message, and every action on it is thread-scoped. A summary with `totalCount == 1` stays a message row and does not change. Reverses items 108, 110 and 111 deliberately, deletes the `*_thread` submenu and its five action names (an `### Upgrading` note), and makes membership the union with no exceptions. **Item 170 is blocked on this** and item 176 gets easier under it |
+| 176 | Undoing a thread-scoped action applies its inverse to messages it never changed | defect | S | **done 2026-08-28**, unreleased, on `thread-row-identity`. `NotmuchWorker::applyTags()` reads each message's tags before writing and reports only the ids whose tags actually MOVED; a `TagCommand` base carries that effective set for both `ThreadTagCommand` and `MessageTagCommand`, which had the same defect on a multi-row selection. `tagsApplied` does NOT fire on an empty effective list, since an empty change would push an undo entry whose inverse adds a tag no message ever carried, the same bug one step later. `sendThreadTagChange` gained `onlyMessageIds` so it keeps its thread-scoped REPAINT while restricting the WRITE: the card that changed on screen and the messages that changed on disk are different sets on purpose. **The spec's own plan said item 177 would make a thread undo honest and shrink this to the multi-row case; that was wrong and is corrected in the spec**, an undo inverts an EFFECT, not a scope |
+| 177 | A thread row means both a message and a conversation, and neither consistently | design | L | **done 2026-08-28**, unreleased, on `thread-row-identity`, eleven commits. Spec: `specs/2026-08-28-thread-row-identity-design.md`. `ThreadListModel::isConversationRow()` is the single predicate and `scopeForSelection()` the single resolver, replacing the `scopeFor()`/`messageScopeFor()` pair that made the CALLER choose. A summary with `totalCount == 1` is unchanged. **Reverses items 108, 110 and 111**, and the user confirmed they are happy to lose the two-tier chips; the `*_thread` submenu and its five action names are deleted with an `### Upgrading` note. Item 112's hiding rule is reversed too: with the absolute entries gone, hiding the toggle on a mixed selection leaves no way to act, so it is a catch-all and the write direction moves with the label. Membership is the union, with two user decisions kept (never evict the current row; an asked-for write evicts at once, an automatic one defers) and one documented lag (a long thread's summary is not updated by a message write, so reading its last unread message waits for the next query). Dashboard from a `ThreadDigest` read by its own worker walk. Two traps found while building: a `QStackedWidget` takes the LARGEST minimum width of its pages and the hidden dashboard was raising the pane's minimum to 395px over MainWindow's 300px floor, caught by an existing resize test; and the pane now holds two `TagStrip`s, so both are named |
+| 178 | Delete and Restore judge a conversation on one message | defect | XS | open, 2026-08-28, split out of item 168 by item 177 and flagged in that spec's "Open, deliberately". `MainWindow::everySelectedRowIsInATrashFolder()` reads `ThreadSummary::firstMessagePath` for any row that is not a message row, which was correct while a thread row MEANT that message and is not correct now that it means the conversation. A conversation is in the trash when ALL of its messages are, so a partly-trashed thread currently answers on whichever message the query returned first: Delete can be hidden on a conversation that still has mail outside the trash, and Restore offered on one that mostly does not. Not data-affecting, both actions are no-ops in the wrong direction, but it is an inconsistency the row-kind rule was supposed to remove. Needs the summary to carry the answer, or the paths of every message, which the digest walk already reads |
| 174 | An external `notmuch new` reaches the index without the pending count noticing | defect | S | open, 2026-08-28, from the notes. Item 54 cleared the count for a sync run by `mailsync.sh`, which is what `SyncMonitor` watches; a bare `notmuch new` (a hand run, or a cron entry that is not the script) takes notmuch's own write lock and touches `/tmp/mbsync.lock` not at all, so nothing observes it. The user's framing is the approach: we own `mailsync.sh` and the whole process |
| 175 | The send countdown says Undo, and cannot be skipped | presentation | XS | open, 2026-08-28, from the notes. Two changes in one control: the button reads Abort, and a second button sends immediately rather than waiting the countdown out |
@@ -1165,56 +1166,6 @@ id and a draft of a reply carries both.
replaced correctly now, so the fork this would have mitigated no longer
happens by that route.
-## 170. A row that stops matching the view only leaves it on the Delete path
-
-**Observed (user, from the notes):** "should we refactor the list UI to be
-responsive so changes are applied immediately instead of waiting for a view
-change to repaint?"
-
-**Cause (verified in the code, 2026-08-26).** Two different properties were
-being called "responsive", and only one of them was built.
-
-The optimistic **repaint** is universal. `ThreadListModel::applyTagChange()`
-covers a thread-scoped write, `applyMessageTagChange()` a message-scoped one
-(items 105 to 111), and `revertPendingTagChange()` undoes either if the write
-is rejected. A chip, a bold row and a dimmed row all move the moment the user
-acts.
-
-The optimistic **membership** is not. `ThreadListModel::removeThreadsWithoutTag()`
-has exactly ONE caller, in `trashMessages()`, added last session because Delete
-strips `inbox` and a deleted message sat in the Inbox view across restarts. The
-ordinary tag path never calls it: neither `sendMessageTagChange()` nor
-`sendThreadTagChange()` asks whether the row still belongs in the view.
-
-So in the Unread view, marking a message read repaints the row and leaves it in
-a list defined by `tag:unread`, which it no longer matches. Un-flagging in the
-Flagged view is the same, and so is removing `inbox` by hand from the Inbox
-view. It corrects itself at the next query or sync, which is exactly the "waits
-for a view change" the note describes.
-
-**Approach.** Not a refactor. `viewFilterTag()` already resolves the view's own
-tag from the query, and `removeThreadsWithoutTag()` already does the removal.
-The gap is that the guard sits in `trashMessages()` rather than at the funnel
-every tag write passes. Move it, or call it from both send paths.
-
-**Constraints.**
-
-- The guard's existing reasoning is what makes this safe and must be kept: only
- a plain `tag:<x>` view has a membership one tag decides. A path query (Trash,
- Sent, Drafts) is unaffected by a tag going away, and a hand-typed query cannot
- be reasoned about. Both are left alone. Without that, marking read in an `id:`
- view would empty the list.
-- A row leaving is not revertible by `revertPendingTagChange()`, which repaints
- rather than reinserts. A REJECTED write would leave the row gone until the
- next query. The move path already carries that exposure; check whether it is
- acceptable at the tag path's much higher frequency, or make the removal wait
- for confirmation there.
-- The inverse case is deliberately out of scope: a row that starts matching
- cannot be inserted optimistically, since the model has no summary for a
- thread the query never returned.
-- Undo goes back through the same funnel, so a removal must not make an undone
- mark-read invisible in the view it was undone in.
-
## 173. The composer is a plain-text editor, not WYSIWYG
**Observed (user, 2026-08-27):** asked for directly while hand-testing item
@@ -1329,41 +1280,3 @@ call the button would make, so skipping is stopping the timer and calling it.
A skip button must not become a default, or the protection is gone for
everyone who learns to press it.
- Which button is the default on Return matters here and is the user's call.
-
-## 176. Undoing a thread-scoped action applies its inverse to messages it never changed
-
-**Observed (user, 2026-08-28):** found while hand-testing item 170. A
-thread-scoped `Mark thread read`, then Ctrl+Z, left almost the whole
-conversation unread rather than restoring the two messages that had been.
-
-**Cause (measured, not read).** `ThreadTagCommand::undo()`
-(`src/mainwindow.h`) sends `sendThreadTagChange(m_threadIds, m_remove, m_add,
-...)`: the tags are inverted and the SCOPE is not. `applyTags()` is a blind
-add/remove over whatever ids the thread resolves to, so the inverse of
-"remove `unread` from 44 messages" is "add `unread` to 44 messages",
-regardless of which of them carried it.
-
-Measured on the live index: thread of 44 messages, 2 unread. Mark thread read
-resolved 44 and removed the tag; the undo resolved 44 and added it, leaving
-43 unread. The 42 that were read before the user touched anything were
-rewritten. `maildir.synchronize_flags` is on, so the Maildir filenames were
-rewritten too and the next sync would have carried it to the server.
-
-**Approach (not decided).** The command has to record what the write actually
-CHANGED, not what it asked for. `applyTags()` is the only place that knows:
-it holds each message open and can report the ids whose tags actually moved.
-That is a worker change (`tagsApplied` carrying the effective set) plus a
-command that stores it.
-
-**Constraints.**
-- The undo stack is this application's substitute for confirmation dialogs
- (CLAUDE.md), so an undo that damages state is worse than the dialog it
- replaces.
-- `MessageTagCommand` has the same shape. It is harmless on a single message,
- where asked and changed agree, and has the same defect on a multi-row
- selection.
-- The five `*_thread` actions are the ones that resolve to a large id set, so
- they carry almost all of the exposure.
-- A test needs a thread whose messages DISAGREE about the tag. Two messages in
- the same state answer identically whichever way the code resolves them,
- which is the trap CLAUDE.md already records for item 87.
diff --git a/docs/superpowers/specs/2026-08-28-thread-row-identity-design.md b/docs/superpowers/specs/2026-08-28-thread-row-identity-design.md
index 3d972cb..09aed6b 100644
--- a/docs/superpowers/specs/2026-08-28-thread-row-identity-design.md
+++ b/docs/superpowers/specs/2026-08-28-thread-row-identity-design.md
@@ -319,9 +319,32 @@ load in flight, which is the trap item 119's rule-count work already recorded.
Delete and Archive absent on reply rows.
3. **Worker.** `loadThreadDigest`, the value struct, its own generation.
4. **Pane.** The dashboard widget, stacked with the message view.
-5. **Item 176.** The undo fix, which this makes easier: a thread action's undo
- covering the whole thread is now honestly what the user asked for, so the
- remaining exposure is only the multi-row message case.
+5. **Item 176.** The undo fix.
+
+ **This step's reasoning was wrong, and what shipped is the opposite of what
+ is written below. Corrected 2026-08-28, after the work was done.** The
+ paragraph said: a thread action's undo covering the whole thread is now
+ honestly what the user asked for, so the remaining exposure is only the
+ multi-row message case. That reads as sound and is not, which is why it is
+ corrected here rather than deleted: someone will otherwise reason their way
+ back to it.
+
+ The flaw is that "what the user asked for" is the SCOPE of the write, and an
+ undo is not the inverse of a scope, it is the inverse of an EFFECT. The user
+ asking to mark a whole conversation read does not make it honest to mark the
+ whole conversation unread afterwards, because most of it was already read
+ before they touched anything. Item 177 makes the thread the honest scope of
+ the WRITE and changes nothing about the undo: measured on a conversation of
+ 44 messages holding 2 unread, the undo left 43 unread either way. Making a
+ row mean a conversation does not reduce the exposure, it makes the largest
+ case reachable by the ordinary gesture rather than by a submenu entry.
+
+ So `ThreadTagCommand::undo()` restricts its write to the ids
+ `NotmuchWorker::applyTags()` reported as actually changed, exactly as
+ `MessageTagCommand` does. The two share a `TagCommand` base for that reason.
+ `sendThreadTagChange` keeps the thread as its REPAINT scope while taking
+ `onlyMessageIds` for the write, because the card that changed on screen and
+ the messages that changed on disk are genuinely different sets.
This is one coherent change rather than a decomposition candidate. Splitting it
across releases would ship a state where rows mean one thing and actions
diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts
index 621668f..d158380 100644
--- a/translations/qtmaildir_it_IT.ts
+++ b/translations/qtmaildir_it_IT.ts
@@ -2150,6 +2150,100 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation>
</message>
</context>
<context>
+ <name>ThreadDashboard</name>
+ <message>
+ <source>just now</source>
+ <translation>proprio ora</translation>
+ </message>
+ <message>
+ <source>%1 min ago</source>
+ <translation>%1 min fa</translation>
+ </message>
+ <message>
+ <source>%1 h ago</source>
+ <translation>%1 h fa</translation>
+ </message>
+ <message>
+ <source>%1 d ago</source>
+ <translation>%1 g fa</translation>
+ </message>
+ <message>
+ <source>Waiting for you</source>
+ <translation>In attesa di te</translation>
+ </message>
+ <message>
+ <source>All caught up</source>
+ <translation>Tutto letto</translation>
+ </message>
+ <message>
+ <source>Mark all read</source>
+ <translation>Segna tutti come letti</translation>
+ </message>
+ <message>
+ <source>Archive</source>
+ <translation>Archivia</translation>
+ </message>
+ <message>
+ <source>Delete</source>
+ <translation>Elimina</translation>
+ </message>
+ <message>
+ <source>(no subject)</source>
+ <translation>(nessun oggetto)</translation>
+ </message>
+ <message>
+ <source>today</source>
+ <translation>oggi</translation>
+ </message>
+ <message numerus="yes">
+ <source>%n day(s)</source>
+ <translation>
+ <numerusform>%n giorno</numerusform>
+ <numerusform>%n giorni</numerusform>
+ </translation>
+ </message>
+ <message numerus="yes">
+ <source>%n person(s)</source>
+ <translation>
+ <numerusform>%n persona</numerusform>
+ <numerusform>%n persone</numerusform>
+ </translation>
+ </message>
+ <message>
+ <source>%1 · %2</source>
+ <translation>%1 · %2</translation>
+ </message>
+ <message numerus="yes">
+ <source>%n message(s)</source>
+ <translation>
+ <numerusform>%n messaggio</numerusform>
+ <numerusform>%n messaggi</numerusform>
+ </translation>
+ </message>
+ <message numerus="yes">
+ <source>%n unread</source>
+ <translation>
+ <numerusform>%n non letto</numerusform>
+ <numerusform>%n non letti</numerusform>
+ </translation>
+ </message>
+ <message>
+ <source>%1 → %2</source>
+ <translation>%1 → %2</translation>
+ </message>
+ <message>
+ <source>busiest %1</source>
+ <translation>più attivo %1</translation>
+ </message>
+ <message numerus="yes">
+ <source>+%n more</source>
+ <translation>
+ <numerusform>+%n altro</numerusform>
+ <numerusform>+%n altri</numerusform>
+ </translation>
+ </message>
+</context>
+<context>
<name>ThreadListModel</name>
<message>
<source>Important</source>