aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
diff options
context:
space:
mode:
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.md265
1 files changed, 263 insertions, 2 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 c3a1f4e..99e3b64 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
@@ -16,8 +16,11 @@ notes. Cite these numbers, not the notes', and do not renumber to reconcile.
**The notes are the upstream source and they keep growing.** The user adds to
them while using the app, so this document goes stale on its own. Items 28 to 35
came from one such pass on 2026-08-04 and included two defects that had gone
-unrecorded here for a while. Compare the two at the start of a session; the
-procedure is in `CLAUDE.md`.
+unrecorded here for a while. Items 39 to 45 came from the 2026-08-05 pass, which
+found one more defect (41, a message body silently dropped by the MIME walk) and
+one item that cannot be planned at all until the user says where the thing it
+manages lives (44). Compare the two at the start of a session; the procedure is
+in `CLAUDE.md`.
Numbering is stable. New items append with the next free number and never
renumber, so a note referring to "item 7" keeps meaning the same thing. An item
@@ -81,6 +84,13 @@ taking that too literally.
| 34 | No overview of the Maildir itself | information | M | open |
| 35 | No refresh of the thread list after a sync | workflow | M | open |
| 36 | `test_mainwindow` cannot reach the worker | testing | S | open, on demand |
+| 39 | Thread list cannot be sorted by clicking a column header | workflow | S | open |
+| 40 | No live filter over the current view | workflow | M | open |
+| 41 | A message whose HTML body carries a `Content-Id` renders blank | correctness | S | open |
+| 42 | "Syncing..." says nothing about what is being synced | feedback | S | open |
+| 43 | No "Mark all read" for the current view | workflow | S | open |
+| 44 | No way to manage the filters applied at sync time | workflow | ? | open, unspecified |
+| 45 | Two Sync buttons on the main window | discoverability | XS | open |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1743,6 +1753,257 @@ rather than modelled.
`MainWindow` and must keep working. The fixture is per-test, not a suite-wide
`initTestCase`, or every case pays for a `notmuch new`.
+## 39. Thread list cannot be sorted by clicking a column header
+
+**Observed (user, 2026-08-05):** "left pane columns order by clicking on the
+column header."
+
+**Cause (verified in code):** nothing sorts. `setSortingEnabled` appears nowhere
+in `src/`, no `QSortFilterProxyModel` exists, and `ThreadListModel` implements no
+`sort()`. The order is whatever the worker emitted, and that is fixed:
+`NotmuchWorker::runQuery()` calls
+`notmuch_query_set_sort(..., NOTMUCH_SORT_NEWEST_FIRST)`
+(`src/notmuchworker.cpp:135`). Clicking a header does nothing because the header
+was never made interactive.
+
+**Approach.** Sort in the model, not in the query. The worker's sort is over
+notmuch's own ordering and cannot express "by From" or "by Subject" at all, and
+re-querying per header click would send the user's place away for a presentation
+change.
+
+- `ThreadListView::setSortingEnabled(true)` plus a `sort()` on the model, or a
+ `QSortFilterProxyModel` between them.
+- Persist the sort column and order into `uistate.conf`, per item 1's rule. A
+ sort that resets on restart is item 1 restated.
+
+**Constraints.**
+
+- **Batched appends are the real difficulty.** Threads arrive in batches of 200
+ through `appendBatch()` while the query is still running, so a sorted view is
+ re-sorted on every batch and rows move under a selection the user is already
+ working in. Decide explicitly: sort only once the query completes, or accept
+ the movement. A proxy model makes this worse rather than better, since it
+ re-sorts on every insert by default.
+- The date column is displayed text but must sort as a timestamp, not as a
+ string. `ThreadSummary` carries the real value; sort on that, not on the
+ rendered cell.
+- Row styling (item 13) and the unread bold are per-row, so they follow the row
+ and need nothing here. Verify anyway after a proxy is introduced: a proxy that
+ forwards only `DisplayRole` drops them silently.
+
+## 40. No live filter over the current view
+
+**Observed (user, 2026-08-05):** "search in current view", spelled out as two
+things: "a light filter applied live on the current view", and "a search bar
+appearing as soon as we type while no entry box is focused".
+
+**Cause (verified in code):** the only search is the query bar, which runs a
+notmuch query and replaces the result set. There is no client-side filtering
+of an existing result: no `QSortFilterProxyModel` anywhere in `src/`, and
+`ThreadListModel` has no filter of its own. Narrowing the current view therefore
+means writing a new notmuch query and losing the view.
+
+**Approach.** Distinct from the query bar, and the distinction is the point: this
+filters rows already fetched, without touching notmuch.
+
+- A filter over the model's loaded rows, matching subject and from, case
+ insensitively. No worker round trip.
+- A filter strip that appears on the first keystroke while no entry box has
+ focus, and disappears on Escape, restoring the full result set.
+
+**Constraints.**
+
+- **Type-to-filter competes with the plain-letter shortcuts.** Item 3's outcome
+ records that a plain-letter `QAction` shortcut is suppressed only while an
+ editable widget has focus, which is exactly the state this feature does not
+ start in. Any binding that is a bare letter would be swallowed by the filter
+ strip or would swallow it. Check the current defaults before choosing the
+ trigger, and prefer appearing only for characters no action claims.
+- Escape already blanks the message pane (item 32). If Escape also closes the
+ filter, decide the precedence explicitly rather than letting whichever handler
+ runs first win.
+- The filter is presentation only: it must not clear the selection, the undo
+ stack, or the query, and the pending-edit count must not move.
+- Interaction with item 39: a filter and a sort over the same rows want the same
+ proxy. Whichever is built first should leave room for the other.
+
+## 41. A message whose HTML body carries a `Content-Id` renders blank
+
+**Observed (user, 2026-08-05):** a specific message from a bulk sender opens
+blank, and the app reports it has no HTML part.
+
+**Cause (verified in code): the inline-part branch runs before the body
+branches, and returns.** `collectParts()` in `src/mimeparser.cpp` tests
+`g_mime_part_get_content_id()` at `:133` and, whenever a part has one, files it
+into `out.inlineParts` and returns at `:139`. The `text/plain` and `text/html`
+assignments at `:142-146` are never reached for that part.
+
+Setting a `Content-Id` on the `text/html` body part is legal and common in
+bulk-sender output. Such a message parses with an empty `htmlBody` and an empty
+`plainBody`, so `ParsedMessage::hasHtml()` (`src/mimeparser.h:115`) is false,
+`HtmlBuilder` falls through to a plain body that is also empty
+(`src/htmlbuilder.cpp:208`), and the pane renders nothing. Both halves of the
+user's observation follow from one wrong ordering.
+
+**Approach.** A `Content-Id` makes a part *referenceable*, not non-displayable.
+The two are independent, and the current code treats them as exclusive.
+
+- Register the part in `inlineParts` as today, and then still let a
+ `text/plain` or `text/html` part fill the corresponding body slot when that
+ slot is empty. Do not return early on the presence of a content id alone.
+- The existing "first one wins" rule (`out.htmlBody.isEmpty()`) already keeps a
+ genuinely inline image from displacing a real body, so a part that is not text
+ is unaffected by this change.
+
+**Constraints.**
+
+- **Do not use `Content-Disposition: inline` as the discriminator.** It is
+ absent far more often than it is correct, and a body part commonly carries no
+ disposition at all. The `attachment` check above it (`:116-117`) is already the
+ right test for "not a body" and should stay the only one.
+- A part that is both the body and a `cid:` target must remain reachable under
+ its id, or a sibling referencing it breaks. Register first, then assign.
+- This is `MimeParser`, which is fixture-tested: the fix needs a fixture message
+ whose `text/html` part carries a `Content-Id`, asserting both that the body
+ renders and that the id still resolves. Write the fixture by hand rather than
+ from real mail, per the no-personal-details rule.
+
+**Verification:** the user's original message renders. The `cid:` rewriting of
+item 15's namespacing is unaffected, which the existing mimeparser tests already
+cover.
+
+## 42. "Syncing..." says nothing about what is being synced
+
+**Observed (user, 2026-08-05):** the only feedback during a manual sync is
+"Syncing" in the status bar. The user asked for the account being synced
+(e.g. `Syncing provider-work`) and the operation in progress (mbsync, notmuch).
+
+**Cause (verified in code): the information is already arriving and is thrown
+away.** `assets/mailsync.sh` streams every mbsync and `notmuch new` line,
+timestamped, through `tee` (`assets/mailsync.sh:75-96`), and `MailSync` emits
+each chunk as `outputReceived` (`src/mailsync.cpp:65-74`), which fills the sync
+log pane. The status label is set once to `tr("Syncing...")`
+(`src/mainwindow.cpp:1569`) and never updated until the run finishes. So this
+needs no change to the script and no new channel; it needs the existing stream
+read for state.
+
+**Approach.** Derive a short status from the output already being received.
+
+- Recognise the phase from the stream: lines before `notmuch new` starts are
+ mbsync's, and `notmuch new` announces itself. Show "Syncing mail (mbsync)"
+ then "Reindexing (notmuch)".
+- mbsync prints the channel it is working on, which is the account name the user
+ wants to see. Take it from the output rather than from config, so what is shown
+ is what is actually happening.
+
+**Constraints.**
+
+- **Sync output is untrusted-ish input.** It comes from a local script, but it is
+ interpolated into a status label; keep it plain text and truncate it, so a long
+ or hostile line cannot resize the status bar or inject markup.
+- The status label is shared with transient messages, which expire (item 33).
+ A sync phase is not transient and must not be cleared by that timer, nor
+ clobber a message the user is reading.
+- Do not parse the output to decide success or failure. The exit status is the
+ authority, deliberately (`assets/mailsync.sh:101-108`), and a second opinion
+ derived from text would eventually disagree with it.
+- Match loosely. mbsync's exact wording varies by version, and a status line that
+ goes blank because a string moved is worse than the current fixed one.
+
+## 43. No "Mark all read" for the current view
+
+**Observed (user, 2026-08-05):** a "Mark All Read" button next to Sync, Archive,
+Delete and Undo, applying to the current view.
+
+**Cause (verified in code):** no such action exists. The registered actions are
+the list at `src/mainwindow.cpp:590-756`; there is `toggle_unread`, which acts on
+the selection, and nothing that acts on a whole result set.
+
+**Approach.** The machinery is already there and this is mostly a question of
+scope. `applyTagsToThreads` resolves a multi-thread selection in one combined
+query, per `CLAUDE.md`, so marking many threads read is one write, not N.
+
+- An action removing `unread` from every thread in the current view, routed
+ through the same funnel, with its inverse pushed onto the undo stack as a
+ single command.
+- Item 25 already established select-all, so "select all, then toggle unread" is
+ the manual route today. Decide whether this item is that, or genuinely
+ view-wide regardless of selection.
+
+**Constraints.**
+
+- **"The current view" is not the same as "the loaded rows".** Threads arrive in
+ batches and a large query may still be running, so an action taken mid-load
+ would silently skip whatever has not arrived. Either act on the model's rows
+ and say so, or wait for the query to complete. Do not describe it as "all" if
+ it is not.
+- One undo entry for the whole operation, not one per thread. A user who marks
+ 400 threads read and then hits Ctrl+Z expects one press to be enough.
+- No confirmation dialog, per `CLAUDE.md`, even though this touches many threads.
+ Undo is the answer here as everywhere else.
+- The pending-edit count must move by the real number of threads changed, or the
+ quit prompt understates the work at risk.
+
+## 44. No way to manage the filters applied at sync time
+
+**Observed (user, 2026-08-05):** "manage filters to be applied when syncing (view
+existing, edit, delete, create new, copy as new, dry-run)."
+
+**Unspecified, and blocked on a question the user has to answer first: there are
+no such filters in this application.** Nothing in `src/` applies rules at sync
+time; `MailSync` runs one configured command and shows its output, and
+`assets/mailsync.sh` is mbsync plus `notmuch new` under a lock, with no rule
+engine anywhere in it.
+
+So the item is not "expose the existing filters in the UI". It is one of:
+
+- **A UI over rules that live somewhere else**, e.g. the companion `mailctl`
+ project or a hand-written notmuch tagging script the user runs after
+ `notmuch new`. If those exist, this item is an editor for that file and its
+ shape follows that file's format.
+- **A rule engine in qtmaildir**, which is a materially larger piece of work and
+ a change to what this application is: v1 is read-and-organize over an index
+ someone else fills.
+
+**Next step: ask the user which, and where the rules live today.** The
+dry-run request is the strongest hint that they have something in mind that
+already runs, since a dry run only makes sense against rules that exist.
+
+**Constraint if it is built here:** `CLAUDE.md` records that this project does
+no network protocol work at all and that fetching is external. A filter engine
+that rewrites the Maildir would not violate that literally, but it would put
+qtmaildir in the business of moving mail, which is a decision to take
+deliberately rather than by implementing a dialog.
+
+## 45. Two Sync buttons on the main window
+
+**Observed (user, 2026-08-05):** "there's currently 2 Sync buttons on the main
+interface. UX redundant."
+
+**Cause (verified in code): they are two separate widgets built by two separate
+passes.** `m_syncButton` is a `QPushButton` created in `buildUi()`
+(`src/mainwindow.cpp:441`) and placed in the query bar row. Independently, the
+`sync` action registered at `:721` appears on both the File menu and the
+toolbar, from item 3's menu work. Nothing removed the original button when the
+toolbar gained one, so the window shows both.
+
+**Approach.** Drop the standalone `QPushButton` and keep the toolbar action.
+The action carries its shortcut, its enabled state and its menu entry from one
+place, which is the whole point of item 3's conversion; the loose button is the
+last widget that predates it.
+
+**Constraints.**
+
+- **The button is not just a button today.** It is disabled while a sync runs,
+ including one started externally (item 27/29), and `test_mainwindow` asserts on
+ it by name. Whatever replaces it has to carry that state, and the tests need
+ pointing at the action rather than at the widget.
+- Check for other loose widgets doing the same thing before touching this one, so
+ the fix is not repeated per widget later.
+- Removing a visible control is the kind of change that looks like a regression.
+ Confirm with the user which of the two survives; the note says redundant, not
+ which one is wanted.
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering