diff options
| -rw-r--r-- | CHANGELOG.md | 9 | ||||
| -rw-r--r-- | CLAUDE.md | 45 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md | 101 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 58 |
5 files changed, 165 insertions, 59 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index bc3308d..28e1571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ point at which they are stable. ### Added +- **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 + the number of messages it covers. The list is read-only and is a snapshot + taken when it is opened, so it does not rewrite itself while you read it. A + change whose message has since left the index is still listed, saying so, + rather than being dropped: the number you clicked always matches the number + of entries you get. + - **The composer says when it saved a draft.** A status bar reports the age of the last autosave, and an `unsaved content` cue sits beside it whenever there is text newer than that save. The window title carries the platform's @@ -84,10 +84,12 @@ The query row and the message-pane header are **built inline in `MainWindow` and 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`, `RowStyleDelegate`, `CardDelegate`, -`ComposeWindow`, `SendDialog` and `BusyIndicator`; `TagChip` is a namespace of +`TagDialog`, `MessageDetailsDialog`, `PendingChangesDialog`, +`RowStyleDelegate`, `CardDelegate`, `ComposeWindow`, `SendDialog` and +`BusyIndicator`; `TagChip` is a namespace of painting helpers, not a widget, `SearchTerm` is a namespace of query builders, -and `ThreadCidMap`, `CardLayout`, `SearchOffer` and `HeaderRow` are structs. +and `ThreadCidMap`, `CardLayout`, `SearchOffer`, `HeaderRow` and +`PendingChangeRow` are structs. `SubjectDelegate` existed until item 53 and is gone. **The compose units are mostly NAMESPACES, and the same warning applies to @@ -576,6 +578,43 @@ the pane's chips to the empty node the lookup returned, destroying a strip that had been correct. Item 108 made that the ordinary gesture rather than an edge case: the two changes were each correct and broken together. +**A defensive counter for an unreachable case is worse than nothing, because +it blocks the feature that needs the data.** `pendingEditCount()` summed a +fourth term, a bare int for confirmed changes carrying no message ids, added +on the sound reasoning that an edit which cannot be netted must not be lost. +It made the count impossible to LIST: three groups could name what they held +and the remainder could not, so item 119 sat open for a week carrying "a list +cannot be complete without changing how the count is kept". + +The remainder was empty. `NotmuchWorker::applyTags()` is the only emitter of +`tagsApplied()` and returns early on an empty id list, which is the exact +condition the counter required. Reading the code said it was reachable, and +that reading was wrong twice before it was measured: a `qFatal` in the branch +fired in 4 of 70 `test_mainwindow` cases, all four building a `TagChange` by +hand and invoking the slot directly, and a `Q_ASSERT` before the worker's own +emit never fired across the whole suite. **Instrument the branch and run the +suite; do not conclude reachability by reading.** The tests that exercised it +were the evidence it was live, and they were driving it from outside the +production path. + +**`PendingChangesDialog` groups by a RUN, not by a map**, and the snapshot it +consumes is sorted with `std::stable_sort` for that reason: the actions under +one message have to keep the order they were made in, and `QHash` has no order +of its own, so a map would reshuffle the list between openings. +`PendingChangeRow::startsMessage` is carried rather than inferred from a +non-empty subject, because an id the index no longer holds has an EMPTY +subject and must still open a run; inferring it folds a stale row's actions +under the message above it, which is a worse lie than "subject unknown". + +**A queued call carrying a container needs the same suspicion as a `Q_ENUM`.** +An unregistered metatype is dropped at runtime with a warning and the slot +runs with a default. Measured on Qt 6.11: `QList<bool>` and `QList<int>` both +cross `resolvePendingSubjects()` intact with no registration, but a standalone +probe showed `QMetaType::fromName("QList<int>")` invalid while +`QList<bool>` resolved, so the property is not obvious from the type. The test +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 @@ -691,6 +691,17 @@ that a sync has not yet carried over, and clears the count when one succeeds. A **failed** sync leaves the count standing, since the changes really are still unsynced. +**Click the count to see what it holds.** The list names each message with +the actions waiting on it beneath it, so a message you tagged twice appears +once with both. An action taken on a whole thread is one entry, saying how +many messages it covers, because that is what you asked for; an action on a +single message names that message, whether or not it sits in a thread. + +The list is read-only, and it is a snapshot taken when you open it: it does +not rewrite itself while you read it. A change whose message has since left +the index is still listed, saying so, rather than quietly disappearing, so +the number you clicked always matches the number of entries you get. + The count is a lower bound rather than a guarantee: an external `notmuch new` from your own cron can carry changes over without this application noticing. 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 39026cb..7261bd9 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 @@ -8607,3 +8607,104 @@ that instead. **Not built, and left as the item's own recommendation:** writing `P` from a subject heuristic. Rejected on the same grounds the entry gave before the work started. + + +## 119. The unsynced-changes count cannot be opened to see what it counts + +**Observed (user, from the notes):** "the bottom left statusbar message needs to +be clickable and show what 'N unsynced changes' are in a modal window". + +**Cause (verified in the code).** `m_pendingLabel` is a plain `QLabel` added to +the status bar with `addPermanentWidget` (`src/mainwindow.cpp:502-505`). A +`QLabel` has no clicked signal and none is installed, so there is nothing to +click and no route to a list. It carries a tooltip and nothing else. + +**The count is a SUM OVER FOUR SOURCES, and that is what makes this bigger than +it looks.** `pendingEditCount()` returns +`m_pendingTagEdits.size() + m_unnettablePendingEdits + held + heldMoves`. +Three of those can name what they hold: `m_pendingTagEdits` is a +`QHash<QString, bool>` keyed by message id, `m_heldEdits` and `m_heldMoves` are +queues of edits waiting for a sync to end. **`m_unnettablePendingEdits` is a +bare `int`** (`src/mainwindow.h:1248`), deliberately so: it counts confirmed +changes that carry no message ids and therefore cannot be netted against +anything. + +So a dialog built from what is currently kept would list three of the four +groups and then have to account for a remainder it cannot describe. Showing "and +3 more" is worse than the tooltip, because the user opened the window +specifically to find out what those were. + +**Approach.** Two halves, and the second is the real work. + +- The clickable half is small: a label that emits on click (an event filter, or + a flat `QToolButton` styled as a label), plus a dialog listing what the three + describable groups hold. The message pane already resolves an id to a subject. +- The complete half needs `m_unnettablePendingEdits` to become something that + can name its entries. Its comment says why it is an int: understating the + indicator is the direction that costs the user work, so it counts what it + cannot identify rather than dropping it. Making it describable means finding + out what those changes actually are and whether they can carry an id. + +**Constraints.** + +- **The count is deliberately conservative and must stay so.** Item 28 and item + 54 both landed on this indicator being wrong in the direction that made the + user think their work was safe. A dialog that lists fewer changes than the + count claims is the same failure in a new place: reconcile the two, or state + the remainder honestly rather than hiding it. +- **An external `notmuch` run can clear pending changes without this count + noticing**, which the tooltip already admits. A dialog makes that staleness + much more visible, since a listed change may no longer exist. Worth deciding + whether the dialog re-verifies against the database before showing. +- Read-only. This is an information window, not a place to retry or discard a + change; either would be a new mutation path with its own undo question. + +**Size: S** for the clickable half over the three describable groups. **Unknown** +for the fourth, and the item is not complete without it. + +**Closed 2026-08-26.** The blocker above was investigated first and did not +survive: `m_unnettablePendingEdits` counted confirmed changes carrying no +message ids, and `NotmuchWorker::applyTags()` (the only emitter of +`tagsApplied`) returns early on an empty id list, which is that exact +condition. `applyTagsToThreads()` resolves through a query and errors out on +an empty result, so it cannot hand `applyTags()` an empty list either. + +**Measured rather than read**, twice, because reading is what produced the +wrong answer the first time: a `qFatal` in the branch fired in 4 of 70 +`test_mainwindow` cases, all four building a `TagChange` by hand and invoking +the slot directly with no worker, and a `Q_ASSERT` before the worker's own +emit never fired across the whole suite. The counter was deleted and the +guard it shadowed is pinned where it lives, by +`applyTagsWithNoIdsDoesNothing()` in `test_notmuchworker`. + +Built in four commits: the snapshot, the subject resolve, the dialog and the +click, then a sizing fix after a hand test. + +Three decisions the user made, each of which shapes the code: + +- **Scope follows the ACTION, not the storage.** A thread action shows one + thread row with the count of messages it covered; a message action shows + its message. The three queues already encoded this, so nothing is expanded + and nothing is escalated: `HeldEdit` is thread-scoped because a `*_thread` + action made it, and everything else carries message ids. +- **A snapshot, frozen.** Taken at the click and never refreshed under the + user, who asked for exactly this: "if I keep the popup open for 20 minutes, + I don't want the popup to keep updating the info it's showing me." +- **Subjects resolved, stale rows kept.** An id the index no longer holds + still gets a row saying its subject is unknown, because the count the user + clicked has to equal the list they are shown. + +The re-verify question this item worried about mostly dissolved: item 54 +already clears the count when an external sync carries the edits, so a change +applied by cron does not survive to be clicked on. + +`resolvePendingSubjects()` answers POSITIONALLY, one subject per input row, +because one id can legitimately appear on several rows and a combined query +returns a set. `PendingChangeRow::startsMessage` is carried rather than +inferred from a non-empty subject, so an unresolved id still opens a run of +its own instead of folding its actions under the message above it. + +Read-only, per the constraint above. The dialog's height is sized to its +content; that is a hand test, since the offscreen platform returns an +identical frame either way. + 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 5e95804..69aac44 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 @@ -185,7 +185,7 @@ taking that too literally. | 116 | Copy image copies markup instead of the image | defect | XS | **dropped** 2026-08-17, same day. NOT A DEFECT: `wl-paste --list-types` run immediately after a copy reports `image/png`, `application/x-qt-image` and 30 more image flavours. The clipboard is correct and Chromium is behaving. The earlier "text only" reading was taken minutes late off a clipboard that had been overwritten, and a whole cause was theorised on it | | 117 | The message pane offers no Select all | workflow | XS | **done** 2026-08-19, unreleased. `addPaneActions()` supplies it. The call site is NOT covered by a test and cannot be: the production menu needs a real context-menu event. Stated in the test rather than faked | | 118 | No way to empty the trash from inside the app | workflow | S | **done 2026-08-25**, unreleased. Unblocked by 103. `Message > Empty trash...`, scoped to the account selector, no shortcut. The one confirmation in this application, and CLAUDE.md now records it as the single exception rather than leaving it to be discovered. Found a defect while testing: the count claimed messages whose files were already gone | -| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | open, 2026-08-19, from the notes. One of the four things it sums carries no message ids at all, so a list cannot be complete without a change to how the count is kept | +| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | **done** 2026-08-26, unreleased. **The stated blocker was not real**: the fourth term counted confirmed changes with no message ids, and `applyTags()` returns early on exactly that condition, so it could never fire. Measured before removing it, not read. The label opens a read-only list, grouped as the user asked: subject once, actions beneath. Scope follows the ACTION, so a held thread edit stays one thread row and reports its message count. A snapshot, frozen once open | | 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank | | 122 | The README documents a version of the app that no longer exists | documentation | M | **done** 2026-08-23, unreleased, inside item 123 task 13. `trash`, `send_command` and the whole `[compose]` section were undocumented; a Composing section is added and "sending is not implemented" removed. Every default was read from `config.h` rather than from the prose, which caught `send_html` documented as false when it defaults to true | @@ -215,7 +215,7 @@ taking that too literally. | 143 | The formatting buttons are text, where every editor uses icons | presentation | XS | **done** 2026-08-24, unreleased, inside 142. `QIcon::fromTheme` per CLAUDE.md's chrome rule, the words kept as the tooltip, and an action whose theme lacks the name keeps its text rather than rendering an empty button | | 144 | "Also send a formatted copy" is prominent and does not say what it does | presentation | XS | **done** 2026-08-24, unreleased, inside 142. "Send as HTML", icon and text, alone at the right end of the editor bar where it reads as a control of the editor rather than as a formatting button. The Italian entry was refreshed with it, and `lrelease` reports 477 finished, 0 unfinished | | 145 | Cc and Bcc are permanent rows on every composer | presentation | S | **done** 2026-08-24, unreleased, inside 142. A `QToolButton` disclosure beside To:. `revealCcBccIfUsed()` is the load-bearing half the entry called for: it only ever SHOWS, never hides, so nothing but the user's own click can make a field holding an address invisible. `ComposeContext` carries no `bcc` at all, so the seeded-Bcc case can only arrive from a reopened draft, which is what its test drives. The LABEL is hidden with each field: a `QFormLayout` holds the two as separate items, so hiding the line edit alone strands a `Cc:` over empty space | -| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **duplicate of 119**, recorded 2026-08-23 from the notes. Same request, and 119 already carries the blocker: one of the four things the count sums holds no message ids, so a list cannot be complete without changing how the count is kept | +| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **done as 119** 2026-08-26. Duplicate, recorded 2026-08-23 from the notes; closed by the same work | | 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes, and closed with it on 2026-08-25 | | 148 | Ctrl+W does not close the composer | discoverability | XS | **done** 2026-08-24, unreleased. A `QAction` parented to the composer, so it is a WindowShortcut dispatched to the active composer only and the main window's namespace is untouched, exactly like the formatting shortcuts. It calls `close()` rather than doing anything of its own: `closeEvent()` already decides whether the draft is saved, and a second route out that skipped it would lose the message. Not registered in `KeyMap`, so item 132's rules do not apply | | 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased, in TWO passes. The first fixed the cursor within each branch (`End` under Above, `Start` under Below) and the user still saw the old layout, because the branches were already right and the DEFAULT was wrong: `above` shipped, and the layout asked for is what `below` produces. Default flipped, and the composer now focuses the body whenever To: is already filled, which a Reply and a Forward always are. Both halves were invisible to the existing `theQuotePositionDecidesWhereTheQuoteLands`, which asserts the quote's position and never the cursor's | @@ -640,60 +640,6 @@ make Save image work must not make Save link reachable again. The test fails if it does, which is the point: the handler is per-profile, so the natural implementation would light up both entries at once. -## 119. The unsynced-changes count cannot be opened to see what it counts - -**Observed (user, from the notes):** "the bottom left statusbar message needs to -be clickable and show what 'N unsynced changes' are in a modal window". - -**Cause (verified in the code).** `m_pendingLabel` is a plain `QLabel` added to -the status bar with `addPermanentWidget` (`src/mainwindow.cpp:502-505`). A -`QLabel` has no clicked signal and none is installed, so there is nothing to -click and no route to a list. It carries a tooltip and nothing else. - -**The count is a SUM OVER FOUR SOURCES, and that is what makes this bigger than -it looks.** `pendingEditCount()` returns -`m_pendingTagEdits.size() + m_unnettablePendingEdits + held + heldMoves`. -Three of those can name what they hold: `m_pendingTagEdits` is a -`QHash<QString, bool>` keyed by message id, `m_heldEdits` and `m_heldMoves` are -queues of edits waiting for a sync to end. **`m_unnettablePendingEdits` is a -bare `int`** (`src/mainwindow.h:1248`), deliberately so: it counts confirmed -changes that carry no message ids and therefore cannot be netted against -anything. - -So a dialog built from what is currently kept would list three of the four -groups and then have to account for a remainder it cannot describe. Showing "and -3 more" is worse than the tooltip, because the user opened the window -specifically to find out what those were. - -**Approach.** Two halves, and the second is the real work. - -- The clickable half is small: a label that emits on click (an event filter, or - a flat `QToolButton` styled as a label), plus a dialog listing what the three - describable groups hold. The message pane already resolves an id to a subject. -- The complete half needs `m_unnettablePendingEdits` to become something that - can name its entries. Its comment says why it is an int: understating the - indicator is the direction that costs the user work, so it counts what it - cannot identify rather than dropping it. Making it describable means finding - out what those changes actually are and whether they can carry an id. - -**Constraints.** - -- **The count is deliberately conservative and must stay so.** Item 28 and item - 54 both landed on this indicator being wrong in the direction that made the - user think their work was safe. A dialog that lists fewer changes than the - count claims is the same failure in a new place: reconcile the two, or state - the remainder honestly rather than hiding it. -- **An external `notmuch` run can clear pending changes without this count - noticing**, which the tooltip already admits. A dialog makes that staleness - much more visible, since a listed change may no longer exist. Worth deciding - whether the dialog re-verifies against the database before showing. -- Read-only. This is an information window, not a place to retry or discard a - change; either would be a new mutation path with its own undo question. - -**Size: S** for the clickable half over the three describable groups. **Unknown** -for the fourth, and the item is not complete without it. - - ## 121. The thread list shows nothing while a query is running **Observed (user, from the notes):** "can we show a spinner in the left panel |
