diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-28 11:00:49 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-28 11:00:49 +0200 |
| commit | 177d37eeb3126ec34f45bc21b9ef7d6a3e48b051 (patch) | |
| tree | 412bb85b5aa8c93d8f13f1c01ba10211a86aee71 /docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | |
| parent | 6cf4973b761e4be0291d51c8fde8970edae145c0 (diff) | |
| download | qtmaildir-177d37eeb3126ec34f45bc21b9ef7d6a3e48b051.tar.gz qtmaildir-177d37eeb3126ec34f45bc21b9ef7d6a3e48b051.zip | |
docs: specify what a thread row stands for
Four separate questions in one session turned out to be one question: a row
means a message for display and action, and a thread for existence and
membership. The spec settles it as the conversation, and records item 177
plus item 176, the thread-scoped undo defect found while hand-testing.
Diffstat (limited to 'docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md')
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 107 |
1 files changed, 106 insertions, 1 deletions
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 94d36d9..7e7c040 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 @@ -243,10 +243,14 @@ taking that too literally. | 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 | | 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 | open, 2026-08-26, from the notes, **cause found the same day and the premise is NOT stale**. The optimistic REPAINT is universal; the optimistic MEMBERSHIP is not. `removeThreadsWithoutTag()` has exactly one caller, on the move path, so marking a message read in the Unread view repaints the row and leaves it in a list it no longer belongs to | +| 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 | +| 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 | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -1262,3 +1266,104 @@ rich-text composer with the original inside it, editable. `QSyntaxHighlighter` over the plain editor, which is the cheap answer to the same want ("show me what I am writing"). If this is built, 133 is moot; if this is deferred, 133 is the thing to do instead. Do not build both. + +## 174. An external `notmuch new` reaches the index without the pending count noticing + +**Observed (user, from the notes):** "the statusbar still reads that an +external notmuch run can clear the pending edits without the bar noticing. +Since we own mailsync and the whole process, we should fix that." + +**Cause (verified in the code, 2026-08-28).** Item 54 built exactly one +external path and it is narrower than the note assumes. `SyncMonitor` watches +the inode of `/tmp/mbsync.lock` in `/proc/locks` +(`syncmonitor.cpp:64`), which is the file `assets/mailsync.sh` flocks, and +`onExternalSyncStateChanged()` clears `m_pendingTagEdits` only when +`MailSync::lastRunOutcome()` reads an OK from that script's log +(`mainwindow.cpp:4812-4840`). + +A `notmuch new` that is not that script takes notmuch's own write lock inside +the Xapian directory and never touches `/tmp/mbsync.lock`. Nothing observes +it: no state change, no log line to read an outcome from, so the count keeps +reporting work that has already shipped and the exit prompt offers to sync for +it. This is item 54's symptom surviving through the one route item 54 did not +cover. + +**Approach (not decided).** The note names the lever: the process is ours, so +the honest fix is to stop inferring a sync from a lock file and observe the +INDEX instead. `notmuch_database_get_revision()` gives a monotonic uuid plus +revision that moves whenever anything is committed, whoever committed it; the +worker already holds a handle and reopens it. Watching that would cover the +script, a hand run and a cron entry alike, and would make the outcome question +moot: a revision that moved is evidence the write landed, where a log line is +a report about it. + +**Constraints.** +- Clearing the count on an observed revision bump is NOT sound on its own. A + revision moves for mail arriving as well as for our edits landing, so the + bump has to be read as "the index changed, re-check" rather than "our edits + are in". What settles it is the per-message check the pending map can + already do: every entry names a message and a tag. +- `SyncMonitor` stays whatever this becomes. It answers a different question, + "is a sync running", which drives the spinner and the write hold, and a + revision counter cannot answer it. +- Item 125 is open on the same monitor and should be read alongside: a + `mailsync.sh` that exits 75 leaves the spinner running for ever. + +## 175. The send countdown says Undo, and cannot be skipped + +**Observed (user, from the notes):** "the countdown popup has a 'undo' button +that would read better as 'Abort'", and "we could add a 'Send' that skips the +countdown and sends right away." + +**Cause.** Not a defect, a wording and a missing control. `SendDialog` runs +the undo window from item 123's design; the button is labelled for the undo +stack's vocabulary rather than for what it does here, which is to stop +something that has not happened yet. + +**Approach.** Rename the button, add a second one that fires the send +immediately. Both live in `SendDialog`; the timer already ends in the same +call the button would make, so skipping is stopping the timer and calling it. + +**Constraints.** +- The countdown IS the undo for a send, per CLAUDE.md's no-confirmation rule. + 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. |
