aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-08-28-thread-row-identity-design.md
blob: 09aed6b68f77ccff1057f236dbd6afefc9db133e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
# A thread row is the conversation

**Date:** 2026-08-28
**Status:** designed, not built
**Size:** L

## The problem

A row in the thread list means two different things at once, and every
ambiguity the application has about threads comes from that.

Item 66 removed the conversation view, so a thread row's card displays ONE
message. Item 108 then made actions on that row scope to that one message, on
the sound reasoning that what you see is what you act on. Both changes were
right on their own terms. But the row still exists because a THREAD exists: it
is produced by a thread query, it carries a reply count, it expands into
children, and its subject, authors and date describe the conversation rather
than the message.

So a row is a **message** for display and action, and a **thread** for
existence and membership. The user stated the consequence directly:

> as it is today, I don't know how to address a thread, because on one side it
> is a message which happens to have replies, on the other is a discussion, and
> should be handled as such, as a whole.

Four separate questions in one working session turned out to be this one
question wearing different clothes:

- Does reading the displayed message remove the conversation from the Unread
  view? (Answering "yes" made a 44-message thread with two unread replies
  vanish two seconds after it was selected.)
- Does deleting the displayed message remove the conversation from the Inbox
  view? (A shipped test asserts yes; consistency with the above demands no.)
- Whose tags does the card draw, the message's or the thread's? (Items 110 and
  111 answered "the message's first, the thread's second, in two tiers".)
- What does undoing a thread-scoped action cover? (Item 176: it inverts over
  the whole thread while the write touched one message.)

Answered one at a time, they produced four answers that contradict each other.

## The decision

**A thread row stands for the conversation.** It is not a message and does not
carry one.

This reverses item 108 deliberately. That item fixed "the card shows one
message but acts on all of them" by moving the ACTION to match the card; this
fixes the same defect by moving the CARD to match the action. Same fork, other
branch. Item 108's own analysis is still correct about the state it found, and
its work is not wasted: the thread-scoped action bodies it wrote become the
ordinary path.

## Two row kinds

Decided by `ThreadSummary::totalCount`, which the query already carries.

**`totalCount == 1` — a message row.** No expander. One click shows the
message. Every action scopes to it. This is the majority of mail and its
behaviour does not change at all.

The user was explicit that this case must not regress:

> A single email is a thread by concept, yes, but if it has no replies (yet) it
> should be viewed with a single click. The logic has to be able to see the
> difference.

**`totalCount > 1` — a thread row.** It stands for the conversation. It shows
the subject, the participants, the reply count and the THREAD's tags. One click
shows the dashboard (below), never a message. Every action on it is
thread-scoped. Its messages live underneath it and are reached by expanding.

## What a thread row stops carrying

`ThreadSummary::firstMessageId`, `firstMessageTags` and `firstMessagePath` stop
feeding a thread row's identity. Consequences, each of which is a deletion:

- `nodeFor()` no longer seeds `ThreadNode::first` from the summary, and
  `setRootMessageTags()` goes with it.
- The `first.tags` substitution in the thread branch of `data()` goes. A thread
  row's tags are `summary.tags`, notmuch's union, which is simply correct once
  the row is the conversation.
- **Items 110 and 111 are reversed.** The two-tier chip display (own tags first,
  siblings' muted behind them) exists only to reconcile "this card shows one
  message" with "this row is a thread". With the row being the thread, one tier
  is right. `PillOwnCountRole`, `CardLayout::siblingFont()` and
  `CardDelegate::mutedChipColour()` are deleted.

A message ROW keeps everything: `MessageOwnTagsRole` already computes "own"
as a set difference against the parent thread's union, which is exactly the
model this design wants, and needs no change.

The fields stay on `ThreadSummary`. The dashboard and the Sent view still want
to know which message a thread query matched, and the query walk that fills
them is free (measured over 36,615 threads under item 66).

## Actions and scope

**Scope comes from what is selected, never from which menu entry was chosen.**

| Selected | Scope | Label |
|---|---|---|
| Thread row | the conversation | `Mark thread read`, `Archive thread`, `Delete thread` |
| Message row (no replies) | that message | `Mark read`, `Archive`, `Delete` |
| Reply row | that message | `Mark read` |

Labels name their scope, per the user: "Mark Read is fine for a single message,
but right clicking on the thread main row must read Mark thread Read or
something similar". There is precedent: items 99 and 112 already made the
unread action's label depend on the selection, so this is an established
pattern rather than a new mechanism.

**The `Whole thread` submenu is deleted**, and with it the five `*_thread`
action names. Nothing is left to disambiguate once the row's identity decides
the scope.

**Delete and Archive are conversation-level only.** On a reply row they are
ABSENT, not disabled. The user's reasoning:

> I don't think I'd want to be able to remove a single reply from a thread, I
> can mark it with tags, important, spam, etc, I can reply/forward a single
> answer, yes, download that single message, yes, but delete should be
> available only as a conversation.

This is Gmail's model rather than Thunderbird's or mutt's, and it is coherent
with the rest: irreversible actions work on the unit the user can see and
reason about, while reversible per-message things (tags, flags, read state,
reply, forward, save) work wherever they are. It also dissolves the "delete one
reply of five" membership question, which can no longer arise.

**Reply, Forward and Save on a conversation row.** A conversation row shows no
message, so the three actions that need one cannot mean what they usually do.
`composeReply()`'s own comment already stated the principle before item 177:
"Replying to a thread is meaningless; a reply answers a message."

The user's decision: **Forward and Save disappear** on a conversation row, and
one action replaces Reply, **Reply to this thread**:

- It quotes NOTHING. "We just add an answer to the thread."
- It is `Kind::ReplyAll` with `quote == false`, which `composeReply()` already
  supports (the existing `reply_no_quote` action uses the same path), so no new
  compose machinery is needed. Reply-all rather than reply-to-sender, and the
  user's reasoning is what settles it: replying to the sender alone in a
  multi-person thread SILENTLY DROPS everyone else from a conversation they are
  part of. It reads as a reply to the thread and is not one. Reply-to-sender
  remains on an individual message row, where the user has picked one message
  and one correspondent deliberately.
- Its threading headers come from the thread's **newest** message, so
  `In-Reply-To` and `References` land the answer at the end of the conversation
  and the recipients are the current ones.

On a message row — a thread of one, or a reply — Reply, Reply-all, Forward,
Reply without quote and Save all behave exactly as they do today. The dashboard
also offers a route to any of them: clicking an entry under "Waiting for you"
selects that message's row, where the full set applies.

**A mixed conversation's unread toggle is a catch-all, and that is item 112
answered rather than lost.** Item 112 split the thread toggle into two
fixed-direction actions because a union is not a state: a conversation with
some messages read and some unread has no current state to toggle from. With
the submenu gone there is one key, so the rule is:

- Any message unread (including a mixed conversation) reads **Mark thread
  read**, and marks every message read. Mixed is not a special case; it is the
  ordinary one.
- A fully read conversation reads **Mark thread unread**.

Two presses therefore reach either state from anywhere, which is what makes one
key enough. The three-valued `selectionTagPresence()` item 112 introduced is
what answers the question; only the LABEL rule changes, from "hide when the
selection disagrees" to "a mixed selection is treated as unread".

`markAllRead` is untouched. It ignores the selection by design and is
thread-scoped by nature (item 108 recorded this).

## Membership

**The union, one rule, no exceptions.** A thread belongs to a view while ANY of
its messages match it. Reading one message of five does not remove the
conversation from Unread; reading the last one does.

Two properties decided during the same session survive, and both are user
decisions rather than implementation details:

- **A row is never evicted while it is current.** The automatic mark-read fires
  two seconds after selection, so evicting on it takes the row out from under
  the user, with a context menu possibly open on it, before they can mark it
  spam or important. The row leaves when the user moves off it. The user's
  framing: "if a selected row still is accessible for me to act on it, the view
  has its meaning and utility."
- **A write the user asked for evicts at once; an automatic one does not.** The
  distinction is who initiated it, not what it does.

A row that starts matching cannot be inserted optimistically, since the model
holds no summary for a thread the query never returned; that case refreshes.

## The dashboard

Shown when a thread row is selected. The user asked for "a recap of the thread,
a well designed dashboard", and rejected the old conversation view, which
listed messages as stubs that could not be expanded and rendered only the last
two.

Blocks, top to bottom:

1. **Header** — subject, then `N people · N days`, and the account.
2. **Tag chips** — the thread's tags. One tier.
3. **Counts** — messages and unread, with a read-progress bar beneath.
4. **Waiting for you** — the unread messages, newest first, each as sender,
   relative time and subject. **Capped at five**, with a `+N more` link beneath
   when there are more; clicking it expands the thread in the LEFT pane, where
   the full list already lives. When nothing is unread this block is replaced
   by a single **All caught up** line and the rest of the pane is unchanged.
5. **Activity** — a sparkline over the thread's lifetime, with
   `first → last · busiest <day>` beneath it.
6. **Thread actions** — Mark all read, Archive, Delete. **Pinned** to the
   pane's bottom edge rather than scrolling with the content: they are the
   reason the user is on the dashboard, and hunting for them past a long
   participant list is the case the scrolling exists for.

**The dashboard invents no colours.** Every colour on it already means
something elsewhere in the application, which is what keeps it from becoming
decoration:

- **Participants** reuse `Avatar::pixmapFor()` unchanged — the same squircle,
  initials and colour the sender carries on every card in the list.
  `Avatar::colourFor()` hashes the ADDRESS at a fixed saturation and lightness
  (item 169), so a participant is already the same colour in every thread and
  in both themes, with nothing to decide here.
- **Tag chips** use `TagColors::colourFor()`, exactly as a card draws them. One
  tier, so no muting.
- **The read-progress bar** is the palette's highlight, one colour. It shows a
  quantity, not a category.
- **The sparkline** is one colour for the bars with the highlight on the
  busiest bucket. Buckets are not categories either; colouring them per
  participant would read as data and mean nothing.
- **Unread emphasis** is the cue the list already uses, not a new colour.

Deliberately NOT done: shading the participant list by how much each person
has written. It looks meaningful and is not.

**The content scrolls.** Everything above the action strip sits in a
`QScrollArea`: a thread with many participants and several unread messages
overflows the pane, and a dashboard that cannot be scrolled hides the half the
user came for. The action strip is outside it and stays put.

**Not a web view.** The message pane's `QWebEngineView` exists to render mail
from strangers under a locked-down profile; a dashboard is our own widgets over
our own data, so it is a plain widget and none of that security surface applies
to it. `MessageView` gains a second stacked page and switches between them.
A single-message thread shows the message, never the dashboard.

**Sparkline buckets are always 7**, the span divided into seven, with the real
range printed beneath. A fixed bucket count is what keeps the widget's geometry
testable; a thread spanning five days and one spanning two years cannot share a
bucket size, and the label carries the truth.

**An entry under "Waiting for you" is clickable** and selects that message's
row in the list, expanding the thread if needed. Without it the dashboard is a
dead end at the moment it has just told the user what they have not read. The
`+N more` link uses the same mechanism with no target message: it expands the
thread and leaves the selection where it is. `ThreadListView` already expands a
thread on click, so this is one signal from the pane rather than new
machinery.

## Data

One worker entry point, `loadThreadDigest(threadId, generation)`, returning a
plain value struct over queued signals like everything else that crosses the
thread boundary:

```
struct ThreadDigest {
    QString threadId;
    QList<QPair<QString, int>> senders;   // display name, message count
    QVector<MessageRef> unread;           // newest first, at most 5
    int unreadTotal;                      // may exceed unread.size()
    QVector<int> buckets;                 // always 7
    qint64 firstTimestamp, lastTimestamp;
    int busiestBucket;
};
```

Everything comes from the index; no message files are opened, so it costs about
what a thread load costs today. It needs its own generation counter, separate
from the query's: bumping `m_generation` for a digest would discard any thread
load in flight, which is the trap item 119's rule-count work already recorded.

## Testing

- **The two row kinds** are a model-level question and test without a painter:
  a summary with `totalCount == 1` produces a row that reports no children and
  resolves actions to its message; one with `totalCount > 1` reports children
  and resolves to its thread.
- **Membership** 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 records for item 87. Put the
  disagreement under the SECOND thread, so a wrong answer is visible rather
  than accidentally right.
- **The deferred eviction** must be driven through the mark-read TIMER, not by
  calling the send path directly: the deferral keys on the write being
  automatic, so a direct call tests the opposite branch. A bare `MainWindow` in
  a test also runs its startup query against the live index, so a test that
  waits long enough for that to land will see the model repopulate; fire the
  timer with a zero interval and one event-loop turn rather than a `QTRY`.
- **The dashboard** is a widget over a value struct, so its content is
  assertable without rendering: build a `ThreadDigest` and assert what the pane
  shows. Do not count pixels, for every reason under "Rendering probes lie".
- **`ctest -R translations`** must stay clean: the new labels are user-facing
  strings, and a label in an array needs `QT_TRANSLATE_NOOP` with the context
  named on the literal.

## Order of work

1. **Model.** Row kind from `totalCount`; thread rows drop the first-message
   identity; membership by union. The largest single piece, and where the tag
   tier tests change.
2. **Actions.** Scope from the selection, dynamic labels, submenu deleted,
   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.

   **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
another, which is the defect being fixed.

## Upgrading

The five `*_thread` action names are removed. A `[keys]` section naming any of
them refers to an action that no longer exists, and `KeyMap` will report it as
unknown. The replacement is that the ordinary action name now does whatever the
selected row means, so a binding on `toggle_unread` covers both cases and the
thread-specific binding should simply be deleted.

## Open, deliberately

- **Item 168** (Delete hidden when every selected row is already in the trash)
  needs restating in thread terms: a conversation is in the trash when all of
  its messages are. Not part of the core change, and it is a defect if left
  unstated.