aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-28-thread-row-identity.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-28 11:21:44 +0200
committerDanilo M. <danix@danix.xyz>2026-08-28 11:21:44 +0200
commit0133fb982da2dcbbfacb9e89b0de79dd483b39fb (patch)
treed591895ae28775ad72964c566b1af846c22f49cd /docs/superpowers/plans/2026-08-28-thread-row-identity.md
parentb365699099b0c296805d5bf883f3e5afbf4cfce7 (diff)
downloadqtmaildir-0133fb982da2dcbbfacb9e89b0de79dd483b39fb.tar.gz
qtmaildir-0133fb982da2dcbbfacb9e89b0de79dd483b39fb.zip
docs: plan the thread row identity change
Eleven tasks over the five stages the spec sets out, TDD throughout. Tasks 1 to 6 are one coherent change and the tree behaves oddly between 2 and 6, so the first useful hand test is at the end of 6.
Diffstat (limited to 'docs/superpowers/plans/2026-08-28-thread-row-identity.md')
-rw-r--r--docs/superpowers/plans/2026-08-28-thread-row-identity.md1390
1 files changed, 1390 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-28-thread-row-identity.md b/docs/superpowers/plans/2026-08-28-thread-row-identity.md
new file mode 100644
index 0000000..005c5f1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-28-thread-row-identity.md
@@ -0,0 +1,1390 @@
+# Thread Row Identity Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make a thread row stand for the conversation rather than for its first message, so that what a row shows, what an action on it does, and whether it belongs in a view all answer the same question.
+
+**Architecture:** `ThreadSummary::totalCount` splits rows into two kinds. A row with one message behaves exactly as today. A row with replies drops its first-message identity, draws the thread's own tag union, resolves every action to the whole conversation, and renders a dashboard instead of a message. View membership becomes the union with no exceptions. A new worker entry point supplies the dashboard's data as a plain value struct.
+
+**Tech Stack:** C++17, Qt 6.11 (Widgets, Test), libnotmuch, CMake + Ninja, ctest.
+
+**Spec:** `docs/superpowers/specs/2026-08-28-thread-row-identity-design.md`. Read it before Task 1; this plan implements it and does not restate its reasoning.
+
+**Branch:** `thread-row-identity`, off master at `177d37e`.
+
+---
+
+## Before you start
+
+Read `CLAUDE.md` in full. It records traps that have each cost a session, and several apply directly to this work:
+
+- **Never run a test binary without `QT_QPA_PLATFORM=offscreen`**, and never launch `./build/src/qtmaildir` unasked. Each test function builds a `MainWindow`; one direct run throws a hundred windows onto the user's screen. The user has asked for this to stop.
+- **A test needs two threads in DISAGREEING states.** Two threads in the same state answer identically whichever way the code resolves them, so a green test proves nothing. Put the interesting case under the SECOND thread.
+- **Rendering probes lie.** Do not count pixels. Assert on `CardLayout`, on model data, and on value structs.
+- **`ThreadListModel::threadAt(int)` takes a ROW** and is wrong for any index that might be a reply. Use `threadFor(const QModelIndex &)`.
+- **A bare `MainWindow` in a test runs its startup query against the LIVE index.** The default startup query is `tag:unread`. A test that waits long enough will see real mail arrive in its model. Fire timers with a zero interval and one event-loop turn rather than a long `QTRY`.
+
+Build and test commands:
+
+```bash
+cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
+cmake --build build
+ctest --test-dir build --output-on-failure
+QT_QPA_PLATFORM=offscreen ./build/tests/test_threadlistmodel
+```
+
+The full suite takes about 170 seconds. `undoMovesTheMessageBack` fails on master already (item 136) and is not yours to fix.
+
+---
+
+## File Structure
+
+**Created:**
+
+- `src/threaddigest.h` — the `ThreadDigest` value struct crossing the worker boundary. Header only; it is data, and it lives beside `types.h` rather than in it because only the dashboard uses it.
+- `src/threaddashboard.h` / `.cpp` — the dashboard widget. Owns no data of its own: it is handed a `ThreadDigest` and renders it.
+- `tests/test_threaddashboard.cpp` — the dashboard's own tests, over value structs, no rendering.
+
+**Modified:**
+
+- `src/threadlistmodel.h` / `.cpp` — row kinds, the union for membership and tags, `scopeFor`/`messageScopeFor`, deletion of the first-message substitution.
+- `src/mainwindow.h` / `.cpp` — action scoping and labels, menu construction, the dashboard's wiring, membership sync.
+- `src/messageview.h` / `.cpp` — a third pane state beside the message and the placeholder.
+- `src/notmuchworker.h` / `.cpp` — `loadThreadDigest`.
+- `src/carddelegate.h` / `.cpp`, `src/cardlayout.h` / `.cpp` — deletion of the sibling tier.
+- `tests/test_threadlistmodel.cpp`, `tests/test_mainwindow.cpp`, `tests/test_cardlayout.cpp` — updated expectations.
+- `tests/CMakeLists.txt`, `src/CMakeLists.txt` — the new files.
+- `CHANGELOG.md`, `CLAUDE.md`, the backlog.
+
+---
+
+## Task 1: A thread row reports its kind
+
+`ThreadListModel` gains one predicate that everything else keys on. `hasChildren()` already contains this logic; this exposes it as the model's own answer so callers stop re-deriving it.
+
+**Files:**
+- Modify: `src/threadlistmodel.h`, `src/threadlistmodel.cpp`
+- Test: `tests/test_threadlistmodel.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `tests/test_threadlistmodel.cpp`, declaring both in the `private slots:` block:
+
+```cpp
+void TestThreadListModel::aSummaryWithOneMessageIsAMessageRow()
+{
+ ThreadListModel model;
+ ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone"));
+ one.totalCount = 1;
+ one.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ one });
+
+ const QModelIndex row = model.index(0, 0, QModelIndex());
+ QVERIFY2(!model.isConversationRow(row),
+ "a thread of one message is not a conversation: it has no replies "
+ "to stand for, and must open its message on one click");
+}
+
+void TestThreadListModel::aSummaryWithRepliesIsAConversationRow()
+{
+ ThreadListModel model;
+ ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk"));
+ many.totalCount = 4;
+ many.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ many });
+
+ const QModelIndex row = model.index(0, 0, QModelIndex());
+ QVERIFY(model.isConversationRow(row));
+}
+
+void TestThreadListModel::aLoadedThreadTrustsItsChildrenOverItsCount()
+{
+ // notmuch's totalCount counts duplicates, so a "thread of 2" can load with
+ // no replies at all. Once loaded the children are the truth, exactly as
+ // hasChildren() already decides.
+ ThreadListModel model;
+ ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Dupe"));
+ many.totalCount = 2;
+ many.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ many });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m1");
+ root.threadId = QStringLiteral("t1");
+ root.depth = 0;
+ model.setThreadMessages(QStringLiteral("t1"), { root });
+
+ const QModelIndex row = model.index(0, 0, QModelIndex());
+ QVERIFY2(!model.isConversationRow(row),
+ "a thread whose count came from duplicates still claims to be a "
+ "conversation after loading no replies at all");
+}
+
+void TestThreadListModel::aMessageRowIsNeverAConversationRow()
+{
+ ThreadListModel model;
+ ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk"));
+ many.totalCount = 2;
+ many.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ many });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m1");
+ root.threadId = QStringLiteral("t1");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m2");
+ reply.threadId = QStringLiteral("t1");
+ reply.depth = 1;
+ model.setThreadMessages(QStringLiteral("t1"), { root, reply });
+
+ const QModelIndex thread = model.index(0, 0, QModelIndex());
+ const QModelIndex replyRow = model.index(0, 0, thread);
+ QVERIFY(model.isMessageRow(replyRow));
+ QVERIFY2(!model.isConversationRow(replyRow),
+ "a reply row answered yes, so an action on it would scope to the "
+ "whole conversation");
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_threadlistmodel`
+Expected: compilation fails, `'isConversationRow' is not a member of 'ThreadListModel'`.
+
+- [ ] **Step 3: Implement**
+
+In `src/threadlistmodel.h`, beside `isMessageRow`:
+
+```cpp
+ /// Whether this row stands for a CONVERSATION rather than for one message.
+ ///
+ /// The single question every scope, label and membership decision keys on
+ /// (item 177). A row with replies is the conversation; a row without them
+ /// is its message and behaves as it always has. A message row is never
+ /// either, so the answer is false there rather than undefined.
+ bool isConversationRow(const QModelIndex &index) const;
+```
+
+In `src/threadlistmodel.cpp`:
+
+```cpp
+bool ThreadListModel::isConversationRow(const QModelIndex &index) const
+{
+ if (!index.isValid() || isMessageRow(index))
+ return false;
+ if (index.row() < 0 || index.row() >= m_threads.size())
+ return false;
+
+ // A flat view has no conversations by construction: every row is one
+ // message and there is nothing to expand.
+ if (m_flatMode)
+ return false;
+
+ const ThreadNode &node = m_threads.at(index.row());
+
+ // Identical to hasChildren()'s rule, and deliberately so: an expander and a
+ // conversation are the same fact. Once loaded the children are the truth,
+ // which is how a thread whose totalCount counted DUPLICATES stops claiming
+ // to be a conversation it cannot open.
+ if (node.loaded)
+ return !node.children.isEmpty();
+ return node.summary.totalCount > 1;
+}
+```
+
+- [ ] **Step 4: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_threadlistmodel`
+Expected: PASS, and every pre-existing test still passing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/threadlistmodel.h src/threadlistmodel.cpp tests/test_threadlistmodel.cpp
+git commit -S -m "feat: let a row say whether it is a conversation
+
+One predicate for the question every scope, label and membership decision
+in item 177 keys on. It repeats hasChildren()'s rule deliberately: an
+expander and a conversation are the same fact, including that a loaded
+thread trusts its children over a count that included duplicates."
+```
+
+---
+
+## Task 2: A conversation row draws the thread's own tags
+
+Delete the first-message substitution. A conversation row's tags become `summary.tags`, notmuch's union, in one tier.
+
+**Files:**
+- Modify: `src/threadlistmodel.cpp:367-400` (the `PillTagsRole` and `TagsRole` thread branch), `src/threadlistmodel.h`
+- Test: `tests/test_threadlistmodel.cpp`
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+void TestThreadListModel::aConversationRowDrawsTheThreadsTags()
+{
+ // Item 110 made a card draw its first message's tags so a four-message
+ // thread would stop claiming a `signed` its displayed message lacked.
+ // Under item 177 the row IS the conversation, so the union is what it
+ // means and the substitution is wrong.
+ ThreadListModel model;
+ ThreadSummary t = makeThread(QStringLiteral("t1"), QStringLiteral("Talk"));
+ t.totalCount = 4;
+ t.tags = QStringList{ QStringLiteral("inbox"), QStringLiteral("signed") };
+ t.firstMessageId = QStringLiteral("m1");
+ t.firstMessageTags = QStringList{ QStringLiteral("inbox") };
+ model.appendBatch({ t });
+
+ const QModelIndex row = model.index(0, 0, QModelIndex());
+ const QStringList pills =
+ model.data(row, ThreadListModel::PillTagsRole).toStringList();
+
+ QVERIFY2(pills.contains(QStringLiteral("signed")),
+ "the card dropped a tag the conversation carries, so a signed "
+ "thread does not read as one until it is expanded");
+ QCOMPARE(model.data(row, ThreadListModel::TagsRole).toStringList(),
+ t.tags);
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_threadlistmodel aConversationRowDrawsTheThreadsTags`
+Expected: FAIL, "the card dropped a tag the conversation carries".
+
+- [ ] **Step 3: Implement**
+
+In the thread branch of `ThreadListModel::data()`, delete the block that substitutes `node.first.tags` for `summary.tags` and read the summary directly. Delete `PillOwnCountRole` from the role enum in `src/threadlistmodel.h` and every `case` for it.
+
+Delete `ThreadListModel::setRootMessageTags()` (declaration and definition), and in `nodeFor()` delete the block that seeds `node.first` from the summary, leaving:
+
+```cpp
+ThreadListModel::ThreadNode
+ThreadListModel::nodeFor(const ThreadSummary &summary)
+{
+ // No `first` node. A conversation row stands for the thread and draws the
+ // union; a one-message row's message arrives with its children like any
+ // other. Seeding it here is what made a row an ambiguous half-message
+ // (item 177).
+ return ThreadNode{ summary, {}, {}, false };
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_threadlistmodel`
+Expected: the new test PASSES. Several item 110/111 tests now FAIL — that is correct, they assert the behaviour being removed. Delete them: any test naming `PillOwnCountRole`, `setRootMessageTags`, or asserting a card drops a sibling's tag.
+
+- [ ] **Step 5: Fix the one caller**
+
+`src/mainwindow.cpp:4076` calls `m_model->setRootMessageTags(ref.messageId, ref.tags)`. Delete that call and the loop around it if it does nothing else.
+
+- [ ] **Step 6: Run the full suite**
+
+Run: `ctest --test-dir build --output-on-failure`
+Expected: only `undoMovesTheMessageBack` failing.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -S -m "feat: draw a conversation row's own tags, in one tier
+
+Items 110 and 111 reconciled a card that showed one message with a row that
+was a thread. The row is the conversation now, so the union is simply what
+it means: the first-message substitution, PillOwnCountRole and the seeded
+first node all go."
+```
+
+---
+
+## Task 3: Delete the sibling chip tier
+
+The muted second tier has nothing left to show.
+
+**Files:**
+- Modify: `src/cardlayout.h` / `.cpp` (`siblingFont`), `src/carddelegate.h` / `.cpp` (`mutedChipColour`, `chipSize`'s scale)
+- Test: `tests/test_cardlayout.cpp`, `tests/test_carddelegate.cpp` if present
+
+- [ ] **Step 1: Find what to remove**
+
+Run: `grep -rn "siblingFont\|mutedChipColour\|PillOwnCount" src/ tests/`
+Every hit is either a deletion or a caller to simplify.
+
+- [ ] **Step 2: Delete, and simplify the callers**
+
+Remove `CardLayout::siblingFont()` and `CardDelegate::mutedChipColour()` with their declarations. `CardDelegate::chipSize()` loses its scale parameter and calls `TagChip::sizeFor()` at full size. `TagChip::sizeFor()` keeps its scale parameter — it is a general facility and nothing else changes.
+
+- [ ] **Step 3: Delete the tests that assert the tier**
+
+Any test asserting a sibling font ratio, a muted chip colour, or a chip-size scale is asserting a removed feature. Delete rather than adapt.
+
+- [ ] **Step 4: Run**
+
+Run: `cmake --build build && ctest --test-dir build --output-on-failure`
+Expected: only `undoMovesTheMessageBack` failing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -S -m "refactor: remove the sibling chip tier
+
+Nothing is a sibling any more: a conversation row draws the thread's tags
+and a message row draws its own."
+```
+
+---
+
+## Task 4: An action scopes to what the row means
+
+`scopeFor` and `messageScopeFor` are replaced by one resolver that reads the row kind.
+
+**Files:**
+- Modify: `src/threadlistmodel.h` / `.cpp`
+- Test: `tests/test_threadlistmodel.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+void TestThreadListModel::aConversationRowResolvesToItsThread()
+{
+ ThreadListModel model;
+ ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone"));
+ one.totalCount = 1;
+ one.firstMessageId = QStringLiteral("m1");
+ // SECOND, so a wrong answer is visible rather than accidentally right.
+ ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk"));
+ many.totalCount = 4;
+ many.firstMessageId = QStringLiteral("m2");
+ model.appendBatch({ one, many });
+
+ const ActionScope scope =
+ model.scopeForSelection({ model.index(1, 0, QModelIndex()) });
+
+ QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t2") });
+ QVERIFY2(scope.messageIds.isEmpty(),
+ "a conversation row named a message, so an action on it would "
+ "touch one message of the thread it claims to act on");
+ QVERIFY(scope.wholeThread);
+}
+
+void TestThreadListModel::aLoneMessageRowResolvesToItsMessage()
+{
+ ThreadListModel model;
+ ThreadSummary many = makeThread(QStringLiteral("t1"), QStringLiteral("Talk"));
+ many.totalCount = 4;
+ many.firstMessageId = QStringLiteral("m1");
+ ThreadSummary one = makeThread(QStringLiteral("t2"), QStringLiteral("Alone"));
+ one.totalCount = 1;
+ one.firstMessageId = QStringLiteral("m2");
+ model.appendBatch({ many, one });
+
+ const ActionScope scope =
+ model.scopeForSelection({ model.index(1, 0, QModelIndex()) });
+
+ QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m2") });
+ QVERIFY(scope.threadIds.isEmpty());
+ QVERIFY(!scope.wholeThread);
+}
+
+void TestThreadListModel::aReplyRowResolvesToItsMessage()
+{
+ ThreadListModel model;
+ ThreadSummary first = makeThread(QStringLiteral("t1"), QStringLiteral("One"));
+ first.totalCount = 1;
+ first.firstMessageId = QStringLiteral("m0");
+ ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk"));
+ many.totalCount = 2;
+ many.firstMessageId = QStringLiteral("m1");
+ model.appendBatch({ first, many });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m1");
+ root.threadId = QStringLiteral("t2");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m2");
+ reply.threadId = QStringLiteral("t2");
+ reply.depth = 1;
+ model.setThreadMessages(QStringLiteral("t2"), { root, reply });
+
+ const QModelIndex thread = model.index(1, 0, QModelIndex());
+ const ActionScope scope =
+ model.scopeForSelection({ model.index(0, 0, thread) });
+
+ QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m2") });
+ QVERIFY(scope.threadIds.isEmpty());
+}
+
+void TestThreadListModel::aMixedSelectionCarriesBothScopes()
+{
+ ThreadListModel model;
+ ThreadSummary one = makeThread(QStringLiteral("t1"), QStringLiteral("Alone"));
+ one.totalCount = 1;
+ one.firstMessageId = QStringLiteral("m1");
+ ThreadSummary many = makeThread(QStringLiteral("t2"), QStringLiteral("Talk"));
+ many.totalCount = 4;
+ many.firstMessageId = QStringLiteral("m2");
+ model.appendBatch({ one, many });
+
+ const ActionScope scope =
+ model.scopeForSelection({ model.index(0, 0, QModelIndex()),
+ model.index(1, 0, QModelIndex()) });
+
+ QCOMPARE(scope.messageIds, QStringList{ QStringLiteral("m1") });
+ QCOMPARE(scope.threadIds, QStringList{ QStringLiteral("t2") });
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_threadlistmodel`
+Expected: compilation fails, `'scopeForSelection' is not a member`.
+
+- [ ] **Step 3: Implement**
+
+Add to `src/threadlistmodel.h`:
+
+```cpp
+ /// What a selection means, resolved per row from what that row IS.
+ ///
+ /// Replaces the scopeFor()/messageScopeFor() pair, which made the caller
+ /// choose the scope and so let one gesture mean two things (item 177). A
+ /// conversation row contributes its thread, any other row its message, and
+ /// a mixed selection carries both.
+ ActionScope scopeForSelection(const QModelIndexList &selection) const;
+```
+
+In `src/threadlistmodel.cpp`:
+
+```cpp
+ActionScope ThreadListModel::scopeForSelection(
+ const QModelIndexList &selection) const
+{
+ ActionScope scope;
+
+ for (const QModelIndex &index : selection) {
+ if (isConversationRow(index)) {
+ const ThreadSummary &summary = m_threads.at(index.row()).summary;
+ if (scope.threadIds.contains(summary.threadId))
+ continue;
+ scope.threadIds.append(summary.threadId);
+ // totalCount, not the loaded children: an unexpanded conversation
+ // still has all of its messages, and the count is what the status
+ // bar names after the fact.
+ scope.messageCount += summary.totalCount;
+ scope.wholeThread = true;
+ continue;
+ }
+
+ const QString messageId =
+ isMessageRow(index)
+ ? messageAt(index).messageId
+ : (index.row() >= 0 && index.row() < m_threads.size()
+ ? m_threads.at(index.row()).summary.firstMessageId
+ : QString());
+
+ // Skipped rather than widened. Falling back to the thread would
+ // silently act on messages the row does not stand for.
+ if (messageId.isEmpty() || scope.messageIds.contains(messageId))
+ continue;
+ scope.messageIds.append(messageId);
+ scope.messageCount += 1;
+ }
+
+ return scope;
+}
+```
+
+Delete `scopeFor()` and `messageScopeFor()` once Task 5 has moved their callers.
+
+- [ ] **Step 4: Run to verify they pass**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_threadlistmodel`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/threadlistmodel.h src/threadlistmodel.cpp tests/test_threadlistmodel.cpp
+git commit -S -m "feat: resolve a selection's scope from what each row is
+
+One resolver replacing the scopeFor/messageScopeFor pair. The caller no
+longer chooses the scope, which is what let one gesture mean two things."
+```
+
+---
+
+## Task 5: Actions read the selection, and say what they will do
+
+`tagSelected` loses its `TagScope` parameter. The five `*_thread` actions and their submenu go. Labels name their scope.
+
+**Files:**
+- Modify: `src/mainwindow.cpp` (action construction ~1732-1830, `tagSelected` ~5460, the submenu ~5393, `KeyMap::knownActions()` in `src/keymap.cpp`)
+- Test: `tests/test_mainwindow.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+void TestMainWindow::theUnreadActionNamesTheThreadOnAConversationRow()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(model && view);
+
+ ThreadSummary one = makeThread(QStringLiteral("t1"),
+ QStringList{ QStringLiteral("unread") });
+ one.totalCount = 1;
+ ThreadSummary many = makeThread(QStringLiteral("t2"),
+ QStringList{ QStringLiteral("unread") });
+ many.totalCount = 4;
+ model->appendBatch({ one, many });
+
+ auto *action = window.findChild<QAction *>(QStringLiteral("toggle_unread"));
+ QVERIFY(action);
+
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+ const QString onMessage = action->text();
+
+ selectThreadRow(view, 1);
+ QApplication::processEvents();
+ const QString onThread = action->text();
+
+ QVERIFY2(onMessage != onThread,
+ "the label reads the same on a message and on a conversation, so "
+ "nothing tells the user which one the key will act on");
+ QVERIFY2(onThread.contains(QStringLiteral("thread"), Qt::CaseInsensitive),
+ qPrintable(QStringLiteral("a conversation row's label does not "
+ "name the thread: %1").arg(onThread)));
+}
+
+void TestMainWindow::deleteIsAbsentOnAReplyRow()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ QVERIFY(model && view);
+
+ ThreadSummary first = makeThread(QStringLiteral("t1"), {});
+ first.totalCount = 1;
+ ThreadSummary many = makeThread(QStringLiteral("t2"), {});
+ many.totalCount = 2;
+ model->appendBatch({ first, many });
+
+ MessageNode root;
+ root.messageId = QStringLiteral("m1");
+ root.threadId = QStringLiteral("t2");
+ root.depth = 0;
+ MessageNode reply;
+ reply.messageId = QStringLiteral("m2");
+ reply.threadId = QStringLiteral("t2");
+ reply.depth = 1;
+ model->setThreadMessages(QStringLiteral("t2"), { root, reply });
+
+ const QModelIndex thread = model->index(1, 0, QModelIndex());
+ view->expand(thread);
+ const QModelIndex replyRow = model->index(0, 0, thread);
+ view->selectionModel()->select(
+ replyRow, QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows);
+ view->setCurrentIndex(replyRow);
+ QApplication::processEvents();
+
+ auto *del = window.findChild<QAction *>(QStringLiteral("delete"));
+ QVERIFY(del);
+ QVERIFY2(!del->isVisible() || !del->isEnabled(),
+ "Delete is offered on a reply: deleting is a conversation-level "
+ "action and a single reply cannot be removed from a thread");
+}
+
+void TestMainWindow::theWholeThreadSubmenuIsGone()
+{
+ const Config config;
+ MainWindow window(config);
+
+ for (const QString &name : { QStringLiteral("archive_thread"),
+ QStringLiteral("delete_thread"),
+ QStringLiteral("spam_thread"),
+ QStringLiteral("flag_thread"),
+ QStringLiteral("mark_thread_read") }) {
+ QVERIFY2(!window.findChild<QAction *>(name),
+ qPrintable(QStringLiteral("%1 still exists; the scope now "
+ "comes from the row, so a separate "
+ "action is a second answer to a "
+ "settled question").arg(name)));
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow theUnreadActionNamesTheThreadOnAConversationRow deleteIsAbsentOnAReplyRow theWholeThreadSubmenuIsGone`
+Expected: all three FAIL.
+
+- [ ] **Step 3: Implement**
+
+- `tagSelected(add, remove, description)` drops its `TagScope` parameter and calls `m_model->scopeForSelection(rows)`. It already splits on `scope.threadIds` / `scope.messageIds` being non-empty and pushes the matching command; that code is unchanged.
+- Delete the five `addAction(...)` calls for `archive_thread`, `delete_thread`, `spam_thread`, `flag_thread`, `mark_thread_read`, their entries in the icon table, and the `Whole thread` submenu construction at `src/mainwindow.cpp:5393`.
+- Delete those five names from `KeyMap::knownActions()` and from `defaultBindings()`. A `Q_ASSERT` in `KeyMap`'s constructor fires otherwise, and it surfaces in whichever suite builds a `MainWindow` first.
+- Extend `refreshUnreadAction()` — which already sets a dynamic label — to set every scoped action's text from the selection. Use `QT_TRANSLATE_NOOP` only if a literal ends up in an array; a `tr()` at the call site is fine here.
+- In the same refresh, hide `delete`, `restore` and `archive` when every selected row is a reply.
+
+- [ ] **Step 4: Run**
+
+Run: `cmake --build build && ctest --test-dir build --output-on-failure`
+Expected: the three new tests PASS. Tests naming the deleted actions FAIL and must be deleted; tests calling `tagSelected` with a `TagScope` need the argument dropped. `everyActionIsReachableFromAMenu()` and `everyActionCarriesAnIcon()` must still pass — they are what catch a half-finished deletion.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -S -m "feat: scope an action to the row it was invoked on
+
+The five *_thread actions and their submenu are gone: the row's identity is
+what decides the scope, so a second set of actions was a second answer to a
+settled question. Labels name the scope, and Delete is absent on a reply."
+```
+
+---
+
+## Task 6: Membership is the union
+
+**Files:**
+- Modify: `src/threadlistmodel.cpp` (`removeThreadsWithoutTag`), `src/mainwindow.cpp` (the membership sync)
+- Test: `tests/test_mainwindow.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+void TestMainWindow::aConversationStaysWhileAnyMessageMatches()
+{
+ // The reported case: a 44-message thread with two replies still unread.
+ // Reading one message must not evict the conversation.
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && queryEdit);
+
+ queryEdit->setText(
+ config.resolvedQuery(Config::builtinFilter(QStringLiteral("unread")),
+ QString()));
+
+ ThreadSummary other = makeThread(QStringLiteral("t1"),
+ QStringList{ QStringLiteral("unread") });
+ other.totalCount = 1;
+ ThreadSummary big = makeThread(QStringLiteral("t2"),
+ QStringList{ QStringLiteral("unread") });
+ big.totalCount = 44;
+ model->appendBatch({ other, big });
+
+ // One message of the conversation is read. The union still says unread.
+ window.sendMessageTagChangeForTesting({ QStringLiteral("m-one@example.org") },
+ {}, { QStringLiteral("unread") },
+ QStringLiteral("Mark read"));
+
+ QCOMPARE(model->rowCount(QModelIndex()), 2);
+}
+
+void TestMainWindow::aConversationLeavesWhenItsUnionEmpties()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && view && queryEdit);
+
+ queryEdit->setText(
+ config.resolvedQuery(Config::builtinFilter(QStringLiteral("unread")),
+ QString()));
+
+ ThreadSummary keep = makeThread(QStringLiteral("t1"),
+ QStringList{ QStringLiteral("unread") });
+ keep.totalCount = 1;
+ ThreadSummary go = makeThread(QStringLiteral("t2"),
+ QStringList{ QStringLiteral("unread") });
+ go.totalCount = 4;
+ model->appendBatch({ keep, go });
+
+ // Selected elsewhere, so the never-evict-the-current-row rule is not what
+ // is being measured here.
+ selectThreadRow(view, 0);
+ QApplication::processEvents();
+
+ window.sendThreadTagChangeForTesting({ QStringLiteral("t2") }, {},
+ { QStringLiteral("unread") },
+ QStringLiteral("Mark thread read"));
+
+ QCOMPARE(model->rowCount(QModelIndex()), 1);
+ QCOMPARE(model->threadAt(0).threadId, QStringLiteral("t1"));
+}
+```
+
+`sendThreadTagChangeForTesting` does not exist yet; add it in `src/mainwindow.h` beside `sendMessageTagChangeForTesting`, forwarding to `sendThreadTagChange`.
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_mainwindow aConversationStaysWhileAnyMessageMatches aConversationLeavesWhenItsUnionEmpties`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+`removeThreadsWithoutTag(threadIds, tag)` judges on `node.summary.tags` only — never on a first-message node, which no longer exists. Restore the membership sync from the stashed work with that one change, keeping both rules the spec records: never evict the current row, and defer an automatic write's eviction until the selection moves.
+
+The stash is recoverable and is the starting point rather than the answer:
+
+```bash
+git stash list # find the item 170 entry
+git stash show -p stash@{N} -- src/mainwindow.cpp
+```
+
+- [ ] **Step 4: Run**
+
+Run: `cmake --build build && ctest --test-dir build --output-on-failure`
+Expected: only `undoMovesTheMessageBack` failing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -S -m "feat: judge a row's membership on the thread's union
+
+Closes item 170. A conversation belongs to a view while any of its messages
+match, so reading one message of a thread no longer takes the conversation
+out of the Unread view. The current row is never evicted, and an automatic
+write defers its eviction until the selection moves."
+```
+
+---
+
+## Task 7: The digest struct and the worker call
+
+**Files:**
+- Create: `src/threaddigest.h`
+- Modify: `src/notmuchworker.h` / `.cpp`, `src/CMakeLists.txt`
+- Test: `tests/test_notmuchworker.cpp`
+
+- [ ] **Step 1: Write `src/threaddigest.h`**
+
+```cpp
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ */
+#ifndef QTMAILDIR_THREADDIGEST_H
+#define QTMAILDIR_THREADDIGEST_H
+
+#include <QList>
+#include <QMetaType>
+#include <QPair>
+#include <QString>
+#include <QVector>
+
+#include "types.h"
+
+/// What the thread dashboard shows, as a plain value.
+///
+/// Crosses the worker boundary like every other structure here: no
+/// notmuch_* pointer, no widget, no model. Everything in it is read from the
+/// INDEX, so building one opens no message files.
+struct ThreadDigest
+{
+ QString threadId;
+
+ /// Display name and message count, most prolific first.
+ QList<QPair<QString, int>> senders;
+
+ /// The unread messages, NEWEST FIRST, at most kUnreadShown of them.
+ QVector<MessageRef> unread;
+
+ /// How many there really are. The list above is capped, so a count taken
+ /// from its size would under-report on exactly the threads that need the
+ /// number most.
+ int unreadTotal = 0;
+
+ int totalCount = 0;
+
+ /// Always kBuckets entries. A fixed count is what keeps the sparkline's
+ /// geometry testable; a thread spanning five days and one spanning two
+ /// years cannot share a bucket size, so the span is what varies and the
+ /// label beneath carries the truth.
+ QVector<int> buckets;
+
+ qint64 firstTimestamp = 0;
+ qint64 lastTimestamp = 0;
+ int busiestBucket = -1;
+
+ static constexpr int kBuckets = 7;
+ static constexpr int kUnreadShown = 5;
+};
+
+Q_DECLARE_METATYPE(ThreadDigest)
+
+#endif // QTMAILDIR_THREADDIGEST_H
+```
+
+- [ ] **Step 2: Write the failing test**
+
+In `tests/test_notmuchworker.cpp`, against the existing fixture:
+
+```cpp
+void TestNotmuchWorker::aDigestCountsSendersAndUnread()
+{
+ NotmuchFixture fixture;
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("d0@example.org"),
+ QStringLiteral("Digest root"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Root."), false));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("d1@example.org"),
+ QStringLiteral("Re: Digest root"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Tue, 25 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Again."), false,
+ QStringLiteral("d0@example.org")));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("d2@example.org"),
+ QStringLiteral("Re: Digest root"),
+ QStringLiteral("bob@example.org"),
+ QStringLiteral("Wed, 26 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Unread one."), true,
+ QStringLiteral("d0@example.org")));
+ QVERIFY(fixture.index());
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded);
+
+ const QString threadId = worker.threadIdForTesting(
+ QStringLiteral("id:d0@example.org"));
+ QVERIFY(!threadId.isEmpty());
+ worker.loadThreadDigest(threadId, 1);
+
+ QCOMPARE(spy.count(), 1);
+ const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>();
+
+ QCOMPARE(digest.totalCount, 3);
+ QCOMPARE(digest.unreadTotal, 1);
+ QCOMPARE(digest.unread.size(), 1);
+ QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("d2@example.org"));
+
+ // Alice twice, Bob once, most prolific first.
+ QCOMPARE(digest.senders.size(), 2);
+ QCOMPARE(digest.senders.at(0).second, 2);
+ QCOMPARE(digest.senders.at(1).second, 1);
+
+ QCOMPARE(digest.buckets.size(), ThreadDigest::kBuckets);
+ int summed = 0;
+ for (int n : digest.buckets)
+ summed += n;
+ QCOMPARE(summed, 3);
+}
+
+void TestNotmuchWorker::aDigestCapsItsUnreadListButNotItsCount()
+{
+ NotmuchFixture fixture;
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("c0@example.org"),
+ QStringLiteral("Cap root"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Root."), false));
+ for (int i = 1; i <= 8; ++i) {
+ QVERIFY(fixture.addMessage(
+ QStringLiteral("inbox"),
+ QStringLiteral("c%1@example.org").arg(i),
+ QStringLiteral("Re: Cap root"),
+ QStringLiteral("bob@example.org"),
+ QStringLiteral("Tue, 25 Aug 2026 %1:00:00 +0200")
+ .arg(i, 2, 10, QLatin1Char('0')),
+ QStringLiteral("Reply."), true, QStringLiteral("c0@example.org")));
+ }
+ QVERIFY(fixture.index());
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::threadDigestLoaded);
+ worker.loadThreadDigest(
+ worker.threadIdForTesting(QStringLiteral("id:c0@example.org")), 1);
+
+ const ThreadDigest digest = spy.at(0).at(0).value<ThreadDigest>();
+ QCOMPARE(digest.unreadTotal, 8);
+ QCOMPARE(digest.unread.size(), ThreadDigest::kUnreadShown);
+ // Newest first: c8 is the latest.
+ QCOMPARE(digest.unread.at(0).messageId, QStringLiteral("c8@example.org"));
+}
+```
+
+`threadIdForTesting(query)` may not exist; if not, add it to `NotmuchWorker` returning the first matching thread's id, guarded for tests only.
+
+- [ ] **Step 3: Run to verify it fails**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_notmuchworker`
+Expected: compilation fails, `loadThreadDigest` undeclared.
+
+- [ ] **Step 4: Implement**
+
+Register the metatype beside the struct, not in `MainWindow` — a caller that never constructs one still needs it (`Q_ENUM` trap, CLAUDE.md):
+
+```cpp
+qRegisterMetaType<ThreadDigest>("ThreadDigest");
+```
+
+`loadThreadDigest(const QString &threadId, quint64 generation)` opens read-only, runs `thread:<id>`, walks the messages once collecting sender counts, unread refs and timestamps, buckets the span into `kBuckets`, sorts senders by count and unread by date descending, truncates to `kUnreadShown`, and emits `threadDigestLoaded(ThreadDigest, quint64)`.
+
+Its generation counter is its OWN. Bumping `m_generation` would discard a thread load in flight and blank the message pane because the user selected a row.
+
+Add `src/threaddigest.h` to the `qtmaildir_lib` sources in `src/CMakeLists.txt`.
+
+- [ ] **Step 5: Run**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_notmuchworker`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -S -m "feat: read a thread's digest from the index
+
+Senders, unread messages and an activity histogram for the dashboard, as a
+plain value struct over a queued signal. Everything comes from the index, so
+no message file is opened; the unread list is capped and unreadTotal carries
+the real number."
+```
+
+---
+
+## Task 8: The dashboard widget
+
+**Files:**
+- Create: `src/threaddashboard.h`, `src/threaddashboard.cpp`, `tests/test_threaddashboard.cpp`
+- Modify: `src/CMakeLists.txt`, `tests/CMakeLists.txt`
+
+- [ ] **Step 1: Write the failing test**
+
+`tests/test_threaddashboard.cpp`:
+
+```cpp
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ */
+#include <QtTest>
+
+#include "threaddashboard.h"
+#include "threaddigest.h"
+
+class TestThreadDashboard : public QObject
+{
+ Q_OBJECT
+
+private slots:
+ void itNamesTheUnreadCountItWasGiven();
+ void itSaysAllCaughtUpWhenNothingIsUnread();
+ void itOffersMoreWhenTheListIsCapped();
+ void itOffersNoMoreLinkWhenTheListIsComplete();
+};
+
+static ThreadDigest digestWith(int unreadTotal, int shown)
+{
+ ThreadDigest d;
+ d.threadId = QStringLiteral("t1");
+ d.totalCount = 44;
+ d.unreadTotal = unreadTotal;
+ for (int i = 0; i < shown; ++i) {
+ MessageRef ref;
+ ref.messageId = QStringLiteral("m%1@example.org").arg(i);
+ ref.subject = QStringLiteral("Re: something");
+ d.unread.append(ref);
+ }
+ d.buckets = QVector<int>(ThreadDigest::kBuckets, 1);
+ return d;
+}
+
+void TestThreadDashboard::itNamesTheUnreadCountItWasGiven()
+{
+ ThreadDashboard dashboard;
+ dashboard.setDigest(digestWith(3, 3));
+ QCOMPARE(dashboard.unreadCountShown(), 3);
+ QVERIFY(!dashboard.showingAllCaughtUp());
+}
+
+void TestThreadDashboard::itSaysAllCaughtUpWhenNothingIsUnread()
+{
+ ThreadDashboard dashboard;
+ dashboard.setDigest(digestWith(0, 0));
+ QVERIFY2(dashboard.showingAllCaughtUp(),
+ "a fully read thread shows an empty Waiting for you block rather "
+ "than saying so");
+}
+
+void TestThreadDashboard::itOffersMoreWhenTheListIsCapped()
+{
+ // 20 unread, 5 shown: the link has to name the 15 the pane cannot list, or
+ // the dashboard under-reports exactly when the number matters most.
+ ThreadDashboard dashboard;
+ dashboard.setDigest(digestWith(20, ThreadDigest::kUnreadShown));
+ QCOMPARE(dashboard.hiddenUnreadCount(), 15);
+}
+
+void TestThreadDashboard::itOffersNoMoreLinkWhenTheListIsComplete()
+{
+ ThreadDashboard dashboard;
+ dashboard.setDigest(digestWith(4, 4));
+ QCOMPARE(dashboard.hiddenUnreadCount(), 0);
+}
+
+QTEST_MAIN(TestThreadDashboard)
+#include "test_threaddashboard.moc"
+```
+
+Add `add_qtmaildir_test(threaddashboard)` to `tests/CMakeLists.txt`.
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug && cmake --build build`
+Expected: fails, no `threaddashboard.h`.
+
+- [ ] **Step 3: Implement**
+
+`ThreadDashboard : public QWidget`, built once in its constructor and repopulated by `setDigest()`. Structure, per the spec:
+
+- A `QScrollArea` holding the content; the action strip is its sibling, outside it, pinned to the bottom.
+- Content, top to bottom: header (`QLabel`, `Qt::PlainText` — subjects come from strangers), tag chips, counts with a `QProgressBar`, the Waiting-for-you list, the sparkline.
+- Colours come from `Avatar::pixmapFor()` and `TagColors::colourFor()`. The pane defines none of its own.
+- The sparkline is a small `QWidget` subclass painting `digest.buckets` at fixed height, highlighting `busiestBucket`.
+- Signals: `messageActivated(QString messageId)` from an unread entry, `expandRequested()` from the `+N more` link, and one per action button.
+
+Accessors `unreadCountShown()`, `hiddenUnreadCount()` and `showingAllCaughtUp()` exist so the content is assertable without rendering.
+
+Every user-facing string goes through `tr()`.
+
+- [ ] **Step 4: Run**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_threaddashboard`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -S -m "feat: add the thread dashboard
+
+A widget over a ThreadDigest: header, tags, counts, the unread list capped
+with a link to the rest, and an activity sparkline, scrolling under a pinned
+action strip. It invents no colours."
+```
+
+---
+
+## Task 9: Wire the dashboard into the pane
+
+**Files:**
+- Modify: `src/messageview.h` / `.cpp`, `src/mainwindow.cpp` (`onThreadSelected` ~3854)
+- Test: `tests/test_mainwindow.cpp`
+
+- [ ] **Step 1: Write the failing tests**
+
+```cpp
+void TestMainWindow::selectingAConversationShowsTheDashboard()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(model && view && pane);
+
+ ThreadSummary one = makeThread(QStringLiteral("t1"), {});
+ one.totalCount = 1;
+ ThreadSummary many = makeThread(QStringLiteral("t2"), {});
+ many.totalCount = 4;
+ model->appendBatch({ one, many });
+
+ selectThreadRow(view, 1);
+ QApplication::processEvents();
+
+ QVERIFY2(pane->showingDashboard(),
+ "selecting a conversation rendered a message: the row stands for "
+ "the thread and has no message to show");
+}
+
+void TestMainWindow::selectingALoneMessageShowsTheMessage()
+{
+ const Config config;
+ MainWindow window(config);
+
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<QTreeView *>();
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY(model && view && pane);
+
+ ThreadSummary many = makeThread(QStringLiteral("t1"), {});
+ many.totalCount = 4;
+ ThreadSummary one = makeThread(QStringLiteral("t2"), {});
+ one.totalCount = 1;
+ model->appendBatch({ many, one });
+
+ selectThreadRow(view, 1);
+ QApplication::processEvents();
+
+ QVERIFY2(!pane->showingDashboard(),
+ "a thread of one message showed a dashboard: it must open on one "
+ "click, which is the case the whole split exists to protect");
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Expected: compilation fails, `showingDashboard` undeclared.
+
+- [ ] **Step 3: Implement**
+
+`MessageView` gains `showDashboard(const ThreadDigest &)` and `showingDashboard()`, switching its stack exactly as `showPlaceholder()` already does.
+
+`onThreadSelected` branches on `m_model->isConversationRow(current)`: a conversation requests a digest and shows the dashboard; anything else loads its message as today. The automatic mark-read is **not armed for a conversation row** — there is no displayed message to mark.
+
+Connect `ThreadDashboard::messageActivated` to select that message's row, expanding first, and `expandRequested` to expand the thread.
+
+- [ ] **Step 4: Run**
+
+Run: `cmake --build build && ctest --test-dir build --output-on-failure`
+Expected: only `undoMovesTheMessageBack` failing.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A
+git commit -S -m "feat: show the dashboard when a conversation is selected
+
+A thread row has no message to render, so the pane shows the conversation
+instead. A thread of one message still opens its message on one click, and
+the automatic mark-read is not armed for a row that displays nothing."
+```
+
+---
+
+## Task 10: Item 176, undo covers what the write changed
+
+**Files:**
+- Modify: `src/notmuchworker.h` / `.cpp` (`applyTags`), `src/mainwindow.h` (`MessageTagCommand`)
+- Test: `tests/test_notmuchworker.cpp`, `tests/test_mainwindow.cpp`
+
+- [ ] **Step 1: Write the failing test**
+
+```cpp
+void TestNotmuchWorker::applyTagsReportsOnlyTheMessagesItChanged()
+{
+ // Item 176. Undo inverts the tags and keeps the scope, so the inverse of
+ // "remove unread from 44 messages" was "add unread to 44 messages",
+ // whether or not they carried it. Measured on real mail: a thread of 44
+ // with 2 unread came back with 43 unread.
+ NotmuchFixture fixture;
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("u0@example.org"),
+ QStringLiteral("Read one"),
+ QStringLiteral("alice@example.org"),
+ QStringLiteral("Mon, 24 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body."), false));
+ QVERIFY(fixture.addMessage(QStringLiteral("inbox"),
+ QStringLiteral("u1@example.org"),
+ QStringLiteral("Unread one"),
+ QStringLiteral("bob@example.org"),
+ QStringLiteral("Tue, 25 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Body."), true));
+ QVERIFY(fixture.index());
+
+ NotmuchWorker worker(fixture.configPath());
+ QSignalSpy spy(&worker, &NotmuchWorker::tagsApplied);
+
+ worker.applyTags(TagChange{
+ { QStringLiteral("u0@example.org"), QStringLiteral("u1@example.org") },
+ {}, { QStringLiteral("unread") }, QStringLiteral("Mark read") });
+
+ QCOMPARE(spy.count(), 1);
+ const TagChange applied = spy.at(0).at(0).value<TagChange>();
+ QCOMPARE(applied.messageIds,
+ QStringList{ QStringLiteral("u1@example.org") });
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cmake --build build && QT_QPA_PLATFORM=offscreen ./build/tests/test_notmuchworker applyTagsReportsOnlyTheMessagesItChanged`
+Expected: FAIL, both ids reported.
+
+- [ ] **Step 3: Implement**
+
+In `applyTags`, before freezing each message, read its current tags and record the id only if the change would actually move one. Emit `tagsApplied` with a `TagChange` carrying that reduced list.
+
+`MessageTagCommand` stores the ids from `tagsApplied` rather than the ones it asked for. A thread command's undo is already honest under item 177 — a thread action really does mean the whole conversation — so the remaining exposure is the multi-message case, which this closes.
+
+- [ ] **Step 4: Write the window-level test**
+
+Add to `tests/test_mainwindow.cpp`, declared in the `private slots:` block. It
+must sit BELOW the definition of `notmuchCount()`, which is a file-local static
+declared partway down the file:
+
+```cpp
+void TestMainWindow::undoingAMarkReadRestoresOnlyWhatWasUnread()
+{
+ // Item 176, end to end. The thread has messages in DISAGREEING states:
+ // two in the same state answer identically whichever way the code
+ // resolves them, so a test built on agreement passes against the bug.
+ //
+ // Measured on the user's real mail before the fix: a thread of 44 with 2
+ // unread was marked read, undone, and came back with 43 unread. Because
+ // maildir.synchronize_flags is on, that rewrote the files and would have
+ // reached the server.
+ WorkerBackedWindow backed;
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("ur0@example.org"),
+ QStringLiteral("UR root"), QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("Root body."), false));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("ur1@example.org"),
+ QStringLiteral("Re: UR root"), QStringLiteral("other@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 11:00:00 +0200"),
+ QStringLiteral("Already read."), false,
+ QStringLiteral("ur0@example.org")));
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/inbox"), QStringLiteral("ur2@example.org"),
+ QStringLiteral("Re: UR root"), QStringLiteral("third@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 12:00:00 +0200"),
+ QStringLiteral("The only unread one."), true,
+ QStringLiteral("ur0@example.org")));
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct"),
+ QStringLiteral("Trash")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+ auto *model = window.findChild<ThreadListModel *>();
+ auto *view = window.findChild<ThreadListView *>();
+ auto *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY(model && view && queryEdit);
+
+ const QString cfg = backed.fixture().configPath();
+ const QString thread = QStringLiteral("thread:{id:ur0@example.org}");
+
+ // A `thread:` query, not `tag:unread`: the row must survive the write for
+ // the undo to be driven through the interface at all.
+ queryEdit->setText(thread);
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ QCOMPARE(notmuchCount(cfg, thread), 3);
+ QCOMPARE(notmuchCount(cfg, thread + QStringLiteral(" and tag:unread")), 1);
+
+ // A conversation row, so this is the thread-scoped write.
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ QVERIFY(model->isConversationRow(model->index(0, 0, QModelIndex())));
+ window.findChild<QAction *>(QStringLiteral("toggle_unread"))->trigger();
+
+ QTRY_VERIFY_WITH_TIMEOUT(
+ notmuchCount(cfg, thread + QStringLiteral(" and tag:unread")) == 0,
+ 15000);
+
+ window.findChild<QAction *>(QStringLiteral("undo"))->trigger();
+
+ // ONE message unread again, the one that was. Before the fix this was 3.
+ QTRY_VERIFY_WITH_TIMEOUT(
+ notmuchCount(cfg, thread + QStringLiteral(" and tag:unread")) == 1,
+ 15000);
+ QCOMPARE(notmuchCount(cfg, QStringLiteral("id:ur2@example.org and "
+ "tag:unread")),
+ 1);
+ QCOMPARE(notmuchCount(cfg, QStringLiteral("id:ur0@example.org and "
+ "tag:unread")),
+ 0);
+}
+```
+
+- [ ] **Step 5: Run**
+
+Run: `ctest --test-dir build --output-on-failure`
+Expected: only `undoMovesTheMessageBack` failing.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -S -m "fix: undo only what the write actually changed
+
+Closes item 176. applyTags reports the messages whose tags really moved, and
+a command stores that rather than what it asked for, so undoing a mark-read
+no longer marks the whole conversation unread."
+```
+
+---
+
+## Task 11: Documentation and close-out
+
+**Files:**
+- Modify: `CHANGELOG.md`, `CLAUDE.md`, `docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md`, `translations/qtmaildir_it_IT.ts`
+
+- [ ] **Step 1: Refresh the translation**
+
+```bash
+lupdate-qt6 src/ -ts translations/qtmaildir_it_IT.ts -no-obsolete -locations none
+```
+
+Translate every new string. `lrelease` must report 0 unfinished — it silently DROPS an unfinished string and ships it as English inside an Italian UI.
+
+Run: `ctest --test-dir build -R translations`
+Expected: PASS.
+
+- [ ] **Step 2: Changelog**
+
+Under `[Unreleased]`, in `### Changed`, describe: a thread row is the conversation; one-message rows unchanged; the `Whole thread` submenu is gone and labels name their scope; Delete and Archive are conversation-level; the dashboard. Add an `### Upgrading` section naming the five removed action names and what replaces them.
+
+- [ ] **Step 3: CLAUDE.md**
+
+Rewrite the thread-row sections that item 177 invalidates. Items 108, 110 and 111 are reversed; say so and why, so the next reader does not restore them. Replace the two-tier chip paragraphs with the union rule, and record the row-kind split and the membership rule.
+
+- [ ] **Step 4: Backlog**
+
+Mark items 170, 176 and 177 done with dates and evidence, and move their sections to `2026-08-03-post-0.1.0-usability-closed.md` on this commit. Restate item 168 in thread terms or record it as still open.
+
+- [ ] **Step 5: Full suite and hand-off**
+
+```bash
+ctest --test-dir build --output-on-failure
+```
+
+Expected: only `undoMovesTheMessageBack` failing (item 136, pre-existing).
+
+Do NOT launch the application. Hand it to the user with what to look at: a conversation row showing the dashboard, a one-message row opening on one click, reading a reply not evicting its thread from Unread, and the labels naming their scope.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A
+git commit -S -m "docs: close items 170, 176 and 177"
+```
+
+---
+
+## Notes for whoever executes this
+
+**Tasks 1 to 6 are one coherent change.** Between Task 2 and Task 6 the tree is internally consistent but the application behaves oddly — cards draw thread tags while actions may still be message-scoped. Do not hand it over mid-way for a hand test; the first useful stopping point is the end of Task 6.
+
+**The stash is a starting point, not an answer.** The item 170 work stashed on 2026-08-28 contains a working membership sync, the deferred eviction, and about eight tests. Its `first.tags` handling is wrong under this design. Read it, take the parts that survive, and rewrite the rest.
+
+**Item 136 is not yours.** `undoMovesTheMessageBack` fails on master and has its own open item.