aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
AgeCommit message (Collapse)AuthorFilesLines
3 daysfeat(compose): wire the composer into the main window, item 123compose-and-sendDanilo M.1-1/+43
The reply family is disabled on mail that arrived at an account with no send_command, behind a ribbon in MessageView naming the account and the key to add. save_message is deliberately never disabled: it is the escape hatch for exactly that case. The ribbon is a WIDGET in the pane's layout, never markup inside the web view. Composing HTML from configuration into the one document that renders input from strangers is the wrong direction, and the header row is already a widget for the same reason. Compose itself is disabled only when NO account can send, and that state is not warned about at startup: an installation with no send_command anywhere is a valid read-only installation. Every reply resolves through messageScopeFor(), not threadFor(): a thread row means the one message its card shows. Replying to a thread is meaningless; a reply answers a message. The context is built from the DATABASE rather than the model, the rule Restore already follows, because a row whose state has not been re-queried carries stale values and a reply built from one would carry the wrong recipients. The mail root crosses from the worker as its own signal. There was no route for it at all: mailRootOf() is file-static in notmuchworker.cpp, and item 124 records that composing a destination from database.path writes into the Xapian tree under a split index. The test uses NotmuchFixture::splitIndex(), the only layout where the two accessors disagree. A thread row's path is RELATIVE to the mail root while a message row's is absolute, so the account lookup matched nothing and the reply family was dead on mail from an account that could send. Found by the positive guard test rather than the negative one, which passed throughout for the wrong reason. The quit path checks the failed-save case FIRST. In the ordinary case nothing is lost by saving; there, saving is what is already not working, so the dialog says plainly that quitting loses that text rather than offering a save that will fail again. Both dialogs name the composers, and the ordinary one asks once whatever the count, because three modals in a row is worse than a coarse answer. Its wording says drafts already saved stay in the folder, so Discard cannot read as 'delete my three messages'. The Save loop holds QPointers, not raw pointers. A deleteLater() posted while a nested exec() runs IS processed by that nested loop, measured in a standalone program: the guard nulls before the modal returns. Closing a composer while the quit dialog is up therefore freed a window the loop then called saveDraftNow() on, crashing at the exact moment the application promised to preserve that text. A compose request that matches nothing clears itself and says so. It was cleared only on a match, so a message deleted between selection and Reply left the request armed for the session: Reply did nothing, and the next ordinary click on that message opened a composer nobody asked for while the pane stayed blank. Forward carries the original's attachments, which the context has always had a field for and nothing ever filled, and seeds its HTML toggle from [compose] send_html. Only Reply seeds that from the original. save_message keeps its filename inside the chosen directory and no longer overwrites a file already there. The check was correct and untested: the test asserted through Attachment's helpers rather than through the function production calls, so deleting the containment check outright left it green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvwDptMWxjqhbCmjxwcSZ2
4 daysdocs(backlog): record the undoMovesTheMessageBack flake, item 136Danilo M.1-0/+35
Found while running the suite during item 123 task 10, and checked rather than assumed to be unrelated: with the branch's work stashed out, on a clean tree, it still fails 1 run in 6. A failure that appears during unrelated work gets blamed on the change in front of it unless someone measures. Sized ? deliberately. The race is either in the test's wait or in the Maildir move Delete performs and Undo reverses, and the two have very different consequences: a test that waits wrongly is noise, while a move that races is mail landing in the wrong folder, which this document already records as reaching the mail server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FXF741wz4SY7j5dqvAxMU5
4 daysfeat(compose): transform the markdown buffer for the toolbar, item 123Danilo M.1-0/+73
MarkdownFormat, task 8 of the compose-and-send plan. Three free functions over (text, selection start, selection end) returning the new text and the selection that follows it, so the grammar is tested without a widget. Three gaps in the plan's draft, each now pinned by a test checked against the mutation that breaks it: - QString::lastIndexOf INCLUDES the position it is given, so quoting with the cursor at the end of a line found that line's newline and quoted the FOLLOWING one. The draft's fixtures never placed a cursor there. - A backwards selection was normalised but never tested, so the swap was unguarded; a right-to-left drag is an ordinary gesture and Qt reports the anchor after the cursor. normalise() now swaps and clamps in one place. - A blank line inside a quoted range produced "> " with trailing whitespace, which editors and mail clients strip anyway. It is written bare. Two further defects came out of review: - quote()'s selectionStart was unasserted for any block not starting at line zero. Hardcoding it to 0 passed all nineteen tests, because the one test naming the property quoted the first line, where right and wrong coincide. A wrong selection there means a second press quotes a line the user never selected, and a following Bold bolds the wrong text. - A selection splitting a surrogate pair split the character across the inserted tokens, leaving invalid UTF-16. Not reachable from the toolbar, where arrow keys and mouse hit-testing both move in whole clusters, but reachable by any code computing a position arithmetically. normalise() nudges off a low surrogate; a collapsed cursor moves back on both ends, since widening would turn "insert an empty pair here" into "wrap the emoji". The buttons stack rather than toggle: a second Bold press gives ****this****, and a second Quote press nests. That is what the spec specifies, and the preserved selection exists so a second press can apply a SECOND token. A toggle was built during this task at the user's request and reverted on finding it contradicts the spec at two sites; it is recorded as backlog item 135, where the unanswered question is what replaces bold-then-italic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L
5 daystest(keys): a shortcut is a chosen subset, not a requirement, item 132Danilo M.1-2/+2
everyActionHasAShortcut() was written when the action list was short and every action plausibly deserved a chord. Item 123 adds six more, and under that rule each one consumes a key sequence whether or not anyone would ever press it. Rarely-used actions were being given chords to satisfy a test rather than because a user wanted them. everyActionIsReachableFromAMenu() is the rule that actually matters, and it already has the right shape: it is what stops an action shipping invisible, which is the defect item 103 found when `restore` was reachable by a chord and by nothing a user could see. Discoverability comes from the menu. A shortcut is an accelerator for the things done often. Nothing replaces the deleted test and nothing else needed changing: showShortcutReference() already prints `(unbound)` for an empty sequence, so the code anticipated this and only the test forbade it. Verified rather than assumed: with `tag_rules` unbound in defaultBindings(), an action that is registered, menu-reachable and carries an icon but has no chord at all, the full suite passes. Before this commit it failed. CLAUDE.md's "adding an action is FIVE places" paragraph is updated, including its count of how many are test-enforced, which drops from four to three.
5 daysdocs: lay out the send popup, item 123Danilo M.1-1/+1
Three rows in every state, so nothing reflows: status label, bar, right-aligned Undo. The bar changes mode rather than place, determinate and draining during the countdown because that has measurable progress, indeterminate once send_command starts because a send does not. Undo stays visible after it disables. A control that vanishes re-lays out the popup mid-operation, and a greyed one says why cancelling is no longer possible where an absent one looks like it was never offered. The status label sizes from the longest string it can hold in the current language rather than from its content: Italian "Rimozione della bozza..." is longer than "Removing draft...", so a content-sized label resizes the popup between stages, which is the jumping the fixed layout exists to prevent. Item 134 gains a requirement from this: the extracted widget must expose both bar modes, not only the indeterminate one MainWindow happens to need today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: choose warning surfaces by consequence, item 123Danilo M.1-0/+1
Two corrections from the user, both of which the spec had wrong. A failed sent-copy write was put in the main window's status bar, on the reasoning that the composer closes so the message needs somewhere persistent. Wrong instinct: the fix for "the window is gone" is a dialog, not a quieter surface. It is the one failure here that produces a silent divergence between what the recipient received and what the local archive holds, and nobody discovers that from a line that showed for a few seconds. It gets a modal. A failed autosave stays in the composer but as a persistent banner rather than a status-area line, since the quit path already escalates that state to a dialog and depends on it surviving. Stated as a rule at the head of the section, because the user's point was general: modal for silent divergence, banner for mid-task, status bar only for what is already obvious. Second correction: the composer's busy indicator is not built inline. A second instance of MainWindow's progress-bar-plus-label pairing is where a widget class earns itself, and "this codebase builds small UI inline" describes what the code does rather than justifying repeating it. Item 134 extracts it and converts MainWindow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: specify the composer's formatting toolbar, item 123Danilo M.1-1/+4
The spec said "the editor is plain text" and left it there, which reads as "you are on your own with the syntax". Storage format and editing affordances are separate decisions and only the first was stated. The toolbar is text transformation over the markdown source, not rich-text editing: bold, italic, code, strikethrough, link and quote, selection-aware, with the cursor landing between the tokens when there is no selection. Its shortcuts belong to the composer window's own scope and do not touch KeyMap, which matters for item 132: the two namespaces should not be conflated when that rule is revisited. Live syntax highlighting is a follow-up (item 133) rather than part of this: agreeing with the grammar about nesting and about code spans is the expensive half, and it is better judged after living with the toolbar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: record that cmark-gfm is stock Slackware, item 123Danilo M.1-3/+5
The spec called it a new dependency needing a SlackBuild REQUIRES entry. It is a new dependency, but /var/log/packages/ shows cmark-gfm-0.29.0.gfm.13-x86_64-3 with no _danix tag, so it is stock and REQUIRES lists only non-stock dependencies. Also records the staleness cost accepted with it: cmark-gfm tracks an older CommonMark base (0.29 era) than the stock plain cmark (0.31.2). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: specify compose and send, item 123Danilo M.1-56/+35
Brainstormed with the user. Design only, no code, which is what the item's #plan-only tag asked for. The decision that shaped everything: there is no MTA on the machine, so "an external script on the same model as mailsync.sh" had no model to copy. Send becomes a per-account send_command taking the message on stdin, exactly as [sync] command already works, which keeps the no-network-protocol rule intact without naming an MTA. An account with no send_command is receive-only by construction, which is how one of the five accounts is meant to work. Reply, reply-all and forward are disabled on its mail behind a ribbon that says why. The body is markdown parsed by cmark-gfm rather than a hand-written parser for a limited set: the two share no code, so the small one is deleted wholesale the moment the set widens. Four new units, three of them widget-free and testable without a painter. MessageSender is deliberately a separate unit rather than a method on the composer, so a future outbox wraps the funnel instead of reworking it. Item 123's section is replaced by a pointer to the spec, per this document's own rule for a fully specified item. The brainstorm opened items 128 to 132, including a review of the every-action-has-a-shortcut rule, which the user raised: six more actions takes it past the point where a chord for everything is useful. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: record what the send side actually has, in item 123Danilo M.1-0/+19
The entry said sending should be an external script on the same model as mailsync.sh. Measured on the machine, there is no such model to copy: the fetch side has mbsync and the send side has nothing. No MTA is installed at all, msmtp and sendmail are both absent, and neomutt sends over its own built-in SMTP configured per account in ~/.config/neomutt/accounts/*.rc. So the working setup this application mirrors has no external send path either. That makes the first question a non-UX one, ahead of everything the note lists: sending needs either an MTA the user chooses to install and configure, which is the only shape that keeps the no-network-protocol rule intact, or a decision to relax that rule. Recorded so the brainstorm does not assume the tidier answer, since installing and configuring an MTA is work he has not asked for and the credentials already exist elsewhere. Also notes that all five accounts already configure a drafts folder, so the draft half has somewhere to live before anything is decided. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: sharpen item 114 after a hand test on a loaded imageDanilo M.1-1/+23
Not a regression and not something we removed: Save image is item 114, still open. Item 127 removed Save LINK and deliberately left this one. The circumstances the user reported sharpen it twice. The image had already had its remote content loaded, so m_allowRemote was still true at the click. That flag is live on the shared interceptor and cleared by the next showThread(), so a download handler is subject to whatever it says at the moment of the click rather than at render time. A naive handler therefore looks perfect in exactly this case and fails once the grant is gone, which makes "it worked when I tried it" worthless as evidence. The entry records that both cases must be tested against a message whose grant has been cleared. The second is a corollary of item 127. downloadRequested is per-profile, so connecting it lights up every download entry Chromium offers at once, including the Save link just removed from the menu. An entry being absent from a menu is not the same as the capability being absent, so the handler must decide per request rather than merely exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysfix(pane): drop Save link from a link's context menuDanilo M.1-0/+16
Reported by hand after the item 127 fix: right-clicking a link still offered Save link. It had been deferred to item 114 alongside Save image, on the grounds that both are inert without a downloadRequested handler. That is true and it was the wrong conclusion, because the two are not the same question. Save image is content the message already carries, and item 114 is about making it work. Save link fetches a remote URL chosen by the sender, through the pane's profile, which is the one profile in this application that must never fetch remote content: that is what m_allowRemote and the interceptor exist to prevent. Answering it with a download handler would put a network fetch of attacker-controlled content behind one context-menu entry. Saving what the user actually wants already has a path that never touches the network: saveAttachment(), which writes a MIME part already parsed into memory and sanitises the filename. So it is removed rather than implemented, and the test asserts its absence. Item 114 now carries the constraint that follows: a downloadRequested handler added to make Save image work must not make Save link reachable again, which the natural per-profile implementation would do by default. Mutation checked: dropping the entry from the filter fails the test with "a link action survived: Save link". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysfix(pane): open a target="_blank" link, and drop the dead link actionsDanilo M.1-137/+2
Items 126 and 127, in one sitting because the second is only safe after the first. 126: an anchor carrying target="_blank" did nothing when clicked, with no error and nothing on screen. Chromium routes such a click to QWebEnginePage::createWindow() rather than to acceptNavigationRequest, and MessagePage did not override it, so the base implementation returned nullptr and the URL was discarded before any of our code saw it. Plain anchors were unaffected and already worked, which is why this presented as "HTML mail is broken" while a text mail's links opened: marketing HTML sets _blank on practically every anchor. createWindow() receives a WebWindowType and no URL, so an override cannot simply read the target: it arrives afterwards as a navigation on whatever page is returned. LinkRelayPage is that page. It has no view, hands the URL to the same handler the plain-link path uses, refuses the navigation, and deletes itself. Nothing is ever fetched and no second QWebEngineView is created. 127: OpenLinkInNewTab, OpenLinkInNewWindow and OpenLinkInThisWindow join removeBrowserActions()'s list. Item 100's list is the PAGE actions and was tested by right-clicking the page; these appear only over a link, so it never saw them. CopyLinkToClipboard stays, being the fallback for any link that will not open. The order matters: 126 gives the page a working createWindow(), so those entries would have stopped being dead and started opening links into a tab that does not exist. Testing needed two seams. The click cannot be synthesised, since JavaScript is off in this profile (measured: runJavaScript returns an invalid QVariant) and a synthetic press would depend on the anchor's rect and the desktop's fonts; setUrl() is no substitute because it arrives as NavigationTypeTyped. clickLinkForTest() and relayBlankTargetForTest() drive the real overrides on the real page, and setLinkOpener() substitutes a recorder for QDesktopServices::openUrl. Both routes are asserted rather than only the broken one, since they share a handler now. Three mutations checked and caught, including the filter also removing CopyLinkToClipboard, which a later sweep of "dead link actions" would otherwise take silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: correct item 126, the cause is createWindow, not the interceptorDanilo M.1-60/+80
The first diagnosis was wrong and the user's own follow-up disproved it: a plain-text GitHub mail opens its links correctly while an HTML newsletter does not. If RequestInterceptor blocking https were the cause, neither would work. Verified against the two messages named. The difference is target="_blank". An anchor with no target navigates the main frame and reaches acceptNavigationRequest, which hands it to QDesktopServices::openUrl; that path works today. An anchor asking for a new window is routed by Chromium to QWebEnginePage::createWindow(), which MessagePage does not override, so the base implementation returns nullptr and the click is discarded before any existing code observes it. Marketing HTML uses _blank almost universally, which is what makes it read as "HTML mail is broken". Both messages render HTML, so this was never a text-versus-HTML distinction: the GitHub mail is multipart/alternative and its HTML part is what the pane shows. The entry also drops the proposal to let main-frame navigations through the interceptor. That would have weakened the remote-content protection to fix something it was not causing. Nothing here needs m_allowRemote relaxed: the URL goes to an external browser and the pane fetches nothing. Records the trap that decides the fix's shape: createWindow() receives no URL, only a WebWindowType, so an override returning nullptr discards the target before it can be read. Item 127 is updated to match. OpenLinkInNewTab and OpenLinkInNewWindow fail through the same missing createWindow(), so fixing 126 may make them start working, which is worse rather than better. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: record the dead link click and its context menuDanilo M.1-0/+118
Two defects found by hand, related but separate. 126: clicking a link in a message does nothing. The handler is already there and correct, calling QDesktopServices::openUrl from acceptNavigationRequest, and it has presumably never run. RequestInterceptor denies http and https whenever m_allowRemote is false, which is the default for every message, and it runs on the request before the page is asked whether to accept the navigation. The click is dropped at the network layer with no error, no navigation and no browser. That is the remote-content protection working as designed; the bug is that a deliberate click is indistinguishable from a resource the document fetched itself, at the layer where the decision is currently made. 127: a link's context menu still offers Open in new tab, Open in new window, Save link and Copy link. Item 100 removed the page-level actions and its list names four of them; the link actions are different WebAction values that Chromium adds only over a link, so item 100 never saw them. Two are dead (there are no tabs, and a second view is deliberately never created), one belongs with item 114's missing download handler, and Copy link works and is currently the user's whole workaround for 126. It follows 126, since a working click makes one "Open link" entry the right answer rather than a removal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysdocs: close item 124, record the spinner defect, keep 121 openDanilo M.1-68/+85
Item 124 shipped and is proven: the index moved from a 7200rpm platter to NVMe with the mail staying at /data/Mail. Cold start went from 38618 ms to 668 ms for a complete walk, and 2008 ms to 50 ms for the first rows. Counts held at 49174 messages / 5594 inbox / 100 tags at every step, and Delete then Restore round-tripped through the account's trash by hand. Item 121 stays open, and its entry now says why. The measured platter figures are the evidence FOR building the indicator, not against it: a mechanical disk is the cheap configuration, not an exotic one, and a user with a large Maildir on spinning rust has nowhere to migrate to. Fixing one developer's hardware is not fixing the application. The constraint that pointed at item 124 as the answer is replaced by one saying the opposite, and prefaulting stays rejected on its own merits since it is worst on the low-memory machines most likely to have a slow disk. Item 125 is new, found by hand during the migration. mailsync.sh exits 75 (EX_TEMPFAIL) when another run holds the lock, and the sync indicator never clears; because an edit made during a sync is held until the sync ends, a Delete sat queued for a completion that could not arrive and looked like it had done nothing. Nothing was lost, since held edits reach the disk, but the user cannot tell that. Also records in CLAUDE.md that notmuch_database_get_path() is not the mail root, that database.hook_dir defaults into the index directory and silently stops post-new under a split config, and that the ordinary fixture layout cannot tell the two accessors apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
5 daysfix(worker): read the mail root, not the index directoryDanilo M.1-2/+233
notmuch can be configured with `mail_root` and `path` as separate keys, which puts the Xapian index outside the Maildir. Under that layout notmuch_database_get_path() returns the INDEX directory, and the worker treated it as the mail root at four sites. The consequences are not symmetric. Message paths resolved to `../..` escapes that match no account prefix, which is a display defect. But moveMessages() composes its destination from the same root, so Delete would have written into the Xapian tree: outside the Maildir, invisible to mbsync, and gone from every other client. That is the stranded-mail failure of item 103 with a new cause. notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT) is correct under both layouts, so no conditional is needed. Verified against the live database: with only `path` set it returns the same string as get_path(), making this a no-op for the current configuration. The fixture gains an opt-in splitIndex(). That is load-bearing rather than convenience: in the ordinary layout the index lives inside the mail root and both accessors return the same string, so a test written against it passes whichever one the code uses. All three new tests fail against the old accessor, confirmed by mutation. Also records the finding as backlog item 124, and corrects item 121's timings, which had been copied from item 74 rather than measured. A cold run seven minutes after boot, with the index verifiably unread, gives 2008 ms to the first rows and 38618 ms to a complete list, against the 642 ms and 5714 ms recorded there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
6 daysfeat(pane): offer Select all, and report what a copy copiedDanilo M.1-90/+2
Items 115 and 117, both from the user's notes. Select all was never in Chromium's menu for this pane, measured by hand with a selection active and against a build with removeBrowserActions() reverted, so the filter is not what removed it. MessageView::addPaneActions() supplies it, static and taking the menu, mirroring removeBrowserActions() beside it. Two comments claiming the standard menu already offered it are corrected; either would have sent the next reader down the same three wrong theories the item records. The copy entries all worked and none of them said so. Four now report through the pane's existing statusMessage, each naming what it copied rather than saying "Copied", which is the item's own constraint when three of them sit together in one menu. Connected to the page's own QActions, so the report follows the entry wherever it is triggered from. The two differ in what can be tested, and the tests say so rather than papering over it. The copy path is fully covered: triggering the action runs the production path, and mutations for a duplicated message and an unwired entry both fail. addPaneActions() is covered, but showBodyContextMenu() CALLING it is not and cannot be, since createStandardContextMenu() returns nothing outside a real context-menu event; a mutation deleting that call leaves the suite green, measured. The call site is a hand test and the test file records that so nobody adds an assertion that appears to cover it. The copy strings are QT_TR_NOOP inside an array, which CLAUDE.md warns extracts nothing at file scope. Verified rather than assumed: lupdate found all four under the MessageView context, because the array sits inside a member function. 387 finished, 0 unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysdocs: reconcile the backlog with the user's notesDanilo M.1-0/+56
Two entries in the notes had no item here. Both causes verified in the code rather than copied from the note. Item 119, the unsynced-changes count being clickable, is bigger than it reads. pendingEditCount() sums four sources and one of them, m_unnettablePendingEdits, is a bare int by design: it counts confirmed changes carrying no message ids, so a dialog built from what is currently kept can list three groups and then owes the user a remainder it cannot describe. Item 120 goes to the deferred table, matching where the user filed it. It is not plannable as it stands: nothing records which rule tagged a message, so the information does not exist to display, and creating it means the post-new hook storing something per message, which is a shared-format change across both repos. Everything else in the notes maps to an existing item. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6 daysdocs: close item 103 and tick the delete-to-trash planDanilo M.1-52/+1
Every step of the plan is done. Item 103's section moves to the closed file on the same commit, per this repo's own rule, with its outcome recorded: what was built, the ten defects hand testing found that the suite did not, and the two process gaps closed alongside them. The fact worth carrying forward is the one that damaged real mail. Under mbsync's Create Both, a wrongly named origin folder propagates to the mail server, so any code composing a folder name reaches the server whether it means to or not. Item 118, emptying the trash, remains deferred at the user's request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 daysdocs: file Empty Trash as item 118, point 103 at its specDanilo M.1-20/+64
Item 103's measurement is done, so its section carries the finding and the three constraints that decide whether to open the spec, rather than the design inline. Item 118 is blocked on 103 and is the first action that would destroy mail with no undo, which is why it is filed separately rather than folded in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 daysdocs: reconcile the backlog, close 98/100/102, drop 78 and 116Danilo M.1-181/+269
The reconciliation against the user's own notes found ZERO unrecorded entries, the first clean pass: the 2026-08-16 sweep added items 98 to 104 and those absorbed the whole current "Not done yet" list. Closed this session, sections moved to the closed-items file: 98, 100, 102. Dropped: - 78, at the user's request. Never a defect. Items 85, 23 and 81 already give the whole journey (right-click a value, search it, save the query, make a rule from it); this was only a shortcut across it, and the entry had already said to gather usage evidence first. That evidence never appeared. - 116, the same day it was raised, and its section is kept for the process failure rather than the non-bug. Copy image was reported as copying markup instead of pixels. Two explanations were eliminated by real evidence, and the conclusion drawn was that something more interesting must be wrong; the actual answer was that the measurement distinguishing them was broken. A wl-paste reading taken minutes after the copy showed text flavours only, was explicitly labelled unreliable in the entry, and was then reasoned from anyway. Run immediately after a copy it reports image/png and 30 more, and pasting into GIMP immediately works. A caveat that does not stop the reasoning it qualifies is decoration. Opened: - 112, Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread. The direction comes from notmuch's UNION over the thread, so one unread message anywhere makes the action pick "mark read" and no input reaches the other branch. Third defect from that union after 110. - 113, view source as our own plain-text dialog. - 114, Save image is offered and does nothing: no downloadRequested handler exists anywhere. The user corrected the first proposal, which would have refused remote images on security grounds; once remote content is granted the bytes are already fetched, so saving them is a local copy and blocking it protects nothing. - 115, no confirmation when a copy succeeds. - 117, the pane offers no Select all. NOT caused by item 100: verified against a build with that filter reverted. Three wrong theories preceded that measurement, and the lesson is one item 100 had already written down: a menu built by hand proves nothing about the menu Chromium builds. The changelog's Unreleased section gains Important-as-a-toggle, the rules Note column, the menu fix, and two Upgrading notes.
9 daysfeat(ui): act on the message a row displays, not its whole threadDanilo M.1-77/+340
A thread's card has rendered one message since item 66, but every tag action still acted on the entire conversation. Delete, Archive, Important, Mark spam and Toggle unread now act on the message the card shows; the whole-thread versions move to a "Whole thread" submenu in the Message menu and the thread list's context menu, on Ctrl+Alt+<key>. Closes items 87, 88, 105, 106, 107, 108, 109, 110 and 111. The defects fixed along the way, several found by reading rather than by report: - threadAt(current.row()) answered about the wrong thread for a reply row, because a tree numbers rows per parent. The audit found four live sites, not the one reported: Delete and Toggle unread each chose their DIRECTION from an unrelated thread, and the tag dialog counted the wrong thread's tags. threadFor(index) replaces them. - A message-scoped write made no optimistic model update and no reply row carried a doomed cue, so acting on a reply moved the pending-edit count and changed nothing on screen. - Both toggles read the state of a reply's THREAD, which a message-scoped write never changes, so they were one-way: the second press re-sent a tag the message already had. - flushHeldEdits() re-sent only thread-scoped edits, so a tag change made on one message during a sync was applied to the row, counted as unsynced, and then dropped without ever being written. - applyTagChange() updated a thread's summary but not its loaded replies, leaving an expanded thread's rows describing a state the database no longer held. - A thread's first message is not among its children, so both message-scoped lookups missed it: acting on a root card repainted nothing and emptied the message pane's chip row. - ThreadSummary::tags is notmuch's union over the thread, so a card standing for one message drew tags belonging to its siblings. The worker now reads that message's own tags in the walk that already finds its id, so the split is known before a row is ever opened. The card shows both tiers: its own message's tags at full size, the rest of the conversation's smaller and muted, so nothing appears to vanish when a row is selected. Auto mark-read is message-scoped as a result, and now arms for a reply, which it never did. With maildir.synchronize_flags on, the old thread-wide write reached the server for mail that had never been displayed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfeat(ui): open a thread on its own by double-clicking a rowDanilo M.1-67/+1
Double-clicking any row drills into its thread: the list becomes that thread alone, expanded, and the pane shows the double-clicked row's own message. A reply therefore opens its WHOLE thread with itself selected, never itself alone, which is what the user asked for and is not the obvious reading of "open it by itself". This is recoverStaleThread() triggered by a gesture. That function already ran thread:<id>, expanded the thread when the row arrived, selected the target message once the replies landed, and fell back to the root when the message had gone; all three cases are existing paths through it, so the new code resolves a row to a thread id and a message id and hands both over. The row is reached through the INDEX and never through index.row(): a tree numbers rows per parent, so threadAt(row) on a reply answers about an unrelated thread. That is item 88's trap, avoided here by construction. The first click of a double-click arms the mark-read timer, and the handler cancels it, because a gesture that navigates must not mutate mail. The timer is armed again for whichever row the recovery lands on, so only the arming for the row being left is cancelled. Its test asserts the timer was active beforehand, so it cannot pass by the timer never having been armed at all. The expander keeps its own double-click: ThreadListView::mousePressEvent accepts a press inside its rect and returns, so Qt never pairs one into a double-click there. Nothing is built for getting back. The filter buttons already are that, per the user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysdocs(backlog): correct item 91 to what the user actually describedDanilo M.1-31/+56
The entry said a double-click runs a query naming the row, an id: for a message and a thread: for a thread. That is wrong for a reply, which the user wants to drill to its THREAD with itself selected, not to itself alone. The view is always the whole thread, expanded; only which message the pane shows changes. It also assumed a thread: query would show the conversation. Nothing in the tree auto-expands, so it lands on one collapsed card and the replies still need a click. Both are already solved by recoverStaleThread(), which runs the query, expands the thread, selects the target reply when the replies arrive, and falls back to the thread when the message has gone. Item 91 is that mechanism triggered by a gesture rather than by the stale-thread notice, so the approach is to reuse it rather than write a second selection-after-query path. Recorded alongside: a double-click delivers a single click first, which arms the mark-read timer, so the handler must cancel it rather than marking a message read that the user only passed through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysdocs(backlog): settle item 91's open question, postpone item 92Danilo M.1-62/+19
91 needed one decision, how the user leaves a drill-down, and the answer is that nothing is built for it: "I didn't think of a back action, usually I'd go back to a known list like unread or inbox at that point." The filter buttons already are that and are one click away in every view, so the Back action, the history stack and the restore-the-previous-query scheme are all unnecessary. That also retires the undo-stack concern: a drill-down clears the stack exactly as a typed query does, which is the behaviour the user already expects from the query bar. The item is now fully specified and ready to build at S. 92 is postponed at the user's request: "I don't see the utility, so I don't really know how to answer." The clarification that preceded it named the per-message version, which needs provenance nothing records, costs a format change across both repos against a hook running on real mail every ten minutes, and stays blank on every message already tagged. A feature whose requester cannot say what it is for should not be built. The cheaper substitute is recorded beside it for if the question ever turns out to be "why does this message carry this tag", which rules.json can answer at read time with nothing stored. Section moved to the closed-items file on this commit, per CLAUDE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysdocs(backlog): specify items 91 and 92 from the user's clarificationsDanilo M.1-35/+72
Both were "open, unspecified" and could not be planned from the backlog alone. 91 is not a second window. The user meant a drill-down to the selected thread or message, the same result an `id:` query gives, reached by double-clicking instead of typing. That removes the constraint the entry was mostly made of, since no second QWebEngineView and no extra render process are involved, and takes it from ? to S. What is left to decide is how the user gets back, and that runQuery() clears the undo stack, which a gesture-triggered query would do silently. 92 is the expensive half of the two it held: the user wants a per-message hint, rule-written against hand-applied, and doubts its utility in the same sentence. Nothing records that today, so it is a format change across both repos before it is any pixels, and it stays blank on every message already tagged. Recorded beside it is the cheaper question that may be the real one, "which rule would tag this message", answerable from rules.json at read time with no stored provenance and no hook change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(sync): send held edits before the sync-end refresh reads the databaseDanilo M.1-0/+1
An edit made while a sync is running is held rather than sent, because the worker's read-write open blocks on notmuch's exclusive lock. At sync end onExternalSyncStateChanged() refreshed the list first and flushed the held edits afterwards, so the refresh read a database that still carried the old tag, reconciled it into the model, and overwrote the optimistic update the hold had deliberately left applied. The flush then wrote the tag correctly. The database ended up right and the list ended up wrong, with nothing scheduled to re-read it, which is why it looked like the edit had been lost. Reported by hand: a message read during a sync went back to unread when the sync finished. The flush moves ahead of the refresh and keeps both properties it already had. It stays outside the Idle branch, so edits held when /proc/locks becomes unreadable are not stranded waiting for an Idle that never comes, and it stays after the status-bar retire, so its own "N held changes sent" message survives. Both orders leave identical end state, so the first version of the test passed against the defect: after the handler returns the queue is empty and the write has been sent whichever ran first. flushGenerationForTesting() stamps the query generation at flush time, which is what separates them, and the test fails against the old order with Actual: 3, Expected: 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(sync): re-arm the automatic sync when it skips a concurrent runDanilo M.1-60/+1
runAutoSync() returned without rescheduling when a sync was already in flight. The comment defending it argued the edits were not lost, because they reached the mail store at edit time and the running sync was "very likely" to carry them. Very likely is not always: an edit made after mbsync has already passed that account's mailbox is not carried by it, the timer had fired, nothing re-armed it, and the pending count sat non-zero until a manual sync or the next cron run. Skipping is unchanged and still required by item 71: the cron job holds the same lock and mbsync fails on a second concurrent run. What changes is that the skip schedules another attempt. scheduleAutoSync() re-checks the delay, the sync command and the pending count on the way in, so this cannot arm a sync for nothing, and against a long external sync it re-arms once per debounce interval, which is a timer rather than a sync. The test fires the timer by hand and asserts it is active again afterwards, at the configured interval rather than a shorter one, with the pending indicator still showing. It fails against the old skip path. Item 89's other half is dropped rather than built. The list churn it described is a tag-defined view working as intended: a thread that loses `unread` leaves the Unread view, and the user resolved it by living in the Inbox view instead. Three designs were drafted before asking and none is worth building. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfix(ui): load a thread that was already displayed when the query ranDanilo M.1-1/+2
Running a query blanks the message pane but left m_currentThreadId, m_currentMessageId and m_currentMessageThreadId naming the thread that had been showing. Both selection handlers compare a newly selected row against those to decide whether it is already on display, so a result containing that same thread was recognised as "already showing" and onThreadSelected() was never called. The card painted as selected, the status bar reported one thread, and the pane stayed on the placeholder. This is why it looked like an `id:` query defect. The id is copied out of the details dialog of the message being read, so that thread is current at the moment the query replaces the view. Any query returning a different thread hides the fault entirely. Filed as the unverified half of item 66 and assumed to be the same empty-MessageIdRole failure. It is not: 66's fix was correct and this reproduced against it, so it is recorded as item 96. Four hypotheses were eliminated by measurement first: the row does carry the message id, the account-scoped query does return it, MimeParser parses the reported message (ok, 40701 bytes of HTML), and both real ids resolve bare and quoted. The regression test's first query must open the SAME thread the second one returns; with two different threads it passes against the defect, which is how the first version of it was green. Reverting the fix fails it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysfeat(i18n): wire translations and ship an Italian one (item 22)Danilo M.1-43/+1
Nothing loaded a translation before this: no QTranslator, no .ts file and no build rule, so every string was English whatever the locale said. The language now comes from the environment, LANG=it_IT.UTF-8, and any other locale runs in English as before. The audit found that the tr() discipline was largely holding, and found eight strings that could never be translated into any language. kFields[] in tagrulesdialog.cpp declared the rule-builder field labels with QT_TR_NOOP inside an anonymous namespace, where lupdate reports "tr() cannot be called without context" and extracts nothing, while the use site calls TagRulesDialog::tr() on them at runtime. From, To, Cc, Subject, Tag, Folder, Attachment and Date: the whole vocabulary of the rule builder, absent from every translation file that could ever exist. The source compiles and reads correctly; only lupdate reveals it. Q_DECLARE_TR_FUNCTIONS is not the fix for that case, though it is the fix for a free function calling tr(). Measured against lupdate: a class carrying the macro beside the array still extracts 0 strings, because the context must be attached to the literal itself. QT_TRANSLATE_NOOP names it explicitly and matches the tr() that already reads them, so the use site needed no change. Twenty configuration and keybinding warnings were not translatable either. They are user-facing, reaching the status label and the "Configuration problems" dialog. Config already had the tr() macro; KeyMap needed it. Translating the filter labels then broke startup_query, found in hand testing: a filter's name is a translated label, so `startup_query = Inbox` matched nothing where the filter shows as "In arrivo". The application opened a different view and reported the user's own working config as invalid. Resolution matches the generator as well now, which is stored in queries.json and identical in every locale; the translated name still works. The regression test installs a real QTranslator rather than a stub, since the bug lives in the gap between the stored string and the displayed one, and it writes a queries.json because the warning it asserts on is guarded by a non-empty saved-query list: without one the branch never runs and the test passes against a broken check. main.cpp's --help and --version stay bare printf, as they run before QApplication exists and no translator could serve them. Verified per the backlog's own standard, that lupdate output is the evidence rather than reading: 355 strings extracted with zero context warnings, where before there were 327 with eight; lrelease reporting 355 finished and 0 unfinished; the built .qm loaded in a standalone probe printing "From -> Da" and both Italian plural forms; and the install rule placing it where main.cpp looks. test_translations guards it and was mutation checked, failing on an emptied translation and naming the defect when QT_TRANSLATE_NOOP is reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10 daysdocs: close items 93 and 95, record their trapsDanilo M.1-39/+2
Item 93 ships four built-in filters composing with the account dropdown, and absorbs item 90. Item 95 is the overflow-menu defect found while hand testing it: pre-existing, not caused by 93, and recorded as its own item rather than folded in. Two traps go to CLAUDE.md because they are still true of the code. Qt emits no triggered for a QAction owning a menu, which is why every entry in the saved query menu had always been inert. And a generator must be asked for one account's query rather than having its all-accounts query wrapped, since the wrap returns the right rows by accident of path: being hierarchical and a row-count test cannot tell the two apart. A third goes beside the existing rendering-probe warnings: visualRect reports a real height for a row scrolled out of the viewport, so a non-empty check passes while the pixel loop measures nothing and blames the wrong defect. The Upgrading note covers what a user sees: the row starts with four shipped buttons, a duplicate name means one of theirs is now beside a built-in, and their migrated Sent entry is unpinned for them rather than deleted.
10 daysdocs: open item 94, dropping pinned once the buttons are built-inDanilo M.1-0/+45
The user's end state for the query row is built-in filters only, with every saved query living in the menu. At that point SavedQuery::pinned has nothing left to decide. Blocked on 93 and deliberately separate from it: the four buttons have to be lived with first, and if one of them is wrong, pinning is the escape hatch, which has to still exist to be used. Recorded as a user-visible removal rather than a cleanup. pinned shipped in 0.18.0 as a checkbox in SaveQueryDialog and a right-click action, so removing it is a minor bump with an Upgrading note. The stored field is a separate decision from the UI, and leaving it in queries.json unread is both cheaper and reversible. The spec for 93 gains the ordering rule this resolves: filters first in fixed order, the user's pinned queries after them, and nothing configurable, since the mixed row exists only until 94 lands.
10 daysdocs: spec built-in filters as item 93, fold item 90 into itDanilo M.1-51/+39
Explaining item 90 to the user produced a reframing rather than a fix. The buttons and the "more queries" menu are two different kinds of thing sharing one mechanism: a filter narrows whatever the user is looking at and should compose with the account dropdown, while a saved query is a self-contained destination entitled to set the account itself. Nothing ships as a default today, so the buttons are whatever the user pinned, which the queries.json migration did to every [queries] entry. That drift is the defect. Item 93 ships four built-in filters, Unread, Inbox, Flagged and Sent, as generated entries in the closed kQueryGenerators set that already exists for Sent. The user's own pinned queries are unpinned rather than deleted once the buttons are confirmed working. Three findings from reading the code, all in the spec. A generator must answer per account rather than having its all-accounts query wrapped in a scope, or Sent becomes path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**"), which returns the right rows only because path: is hierarchical. Sent is flat and the other three are not, so the four match in scope and not in view mode. And m_accountBox has no signal connected to it, which is now a decision rather than an omission: changing the account runs nothing, the button is the verb. Item 90's section moves to the closed file, kept in full because its cause and the rules preview that motivated the reset are still true of the code.
10 daysfix(status): count threads as they arrive instead of "Searching..."Danilo M.1-46/+1
Item 74. runQuery() set the status bar once and only queryFinished cleared it, so the bar kept claiming a query was running for the whole walk while rows were visibly arriving behind it. Measured cold against a 1.1 GB index: the first batch reaches the model at 642 ms and the walk finishes at 5714 ms, so five seconds of a slow query read as a frozen one. onThreadsReady now sets the bar from the model's own row count after each batch, which is the number of rows the user can actually see. No timing changes; this only stops the bar from lying. The refresh branch returns before the new line, so a background refresh stays silent exactly as onQueryFinished already keeps it. That silence has its own test, which fails when the write is moved above the guard. beginRefreshForTesting() is a new seam: refreshCurrentQuery() returns early without a worker and a bare window has none, so a test cannot otherwise reach the refresh path.
10 daysdocs: reconcile the backlog with the user's notes, open 89 to 92Danilo M.1-1/+164
The 2026-08-15 pass over ~/Documents/Obsidian/note/notes on qtmaildir.md found four entries with no item here, and one already-closed item with a case that was never verified by hand. 89: runAutoSync() returns without re-arming the timer when a sync is already in flight, so an edit made after the running mbsync passed that account waits for a manual sync or cron. The second half of the same complaint, rows leaving the list mid-interaction, is a design question and is recorded as one rather than guessed at. 90: runSavedQuery() clears the account box for a query that names no account. The line is deliberate and its comment says why, so this needs a decision rather than a fix. 91 and 92 are unspecified, as the notes themselves say. Item 66's row gains the one case its fix should already cover: a single-message id: query whose card would not open is the same empty MessageIdRole failure, unverified against an id: query.
11 daysdocs: close item 66, open 87 and 88, record the row-number trapDanilo M.1-63/+77
Item 66 turns out not to have been the defect it was filed as. The pane was never blank: an unexpanded thread root rendered the CONVERSATION, and the same click rendered one message once the thread had been opened, because the model learned the root's message id only when the replies arrived. The user's step-by-step account is what separated the two halves; two probes against a real database had failed to reproduce the blank pane because there was none. Closed by carrying firstMessageId in the query and removing the conversation view, which the user asked for after being told the stubs not expanding was itself a defect and that the feature was being judged in a broken state. Two defects came out of it and are open. 87: auto mark-read still marks a whole thread, coherent while a root rendered the conversation and not any more. 88: threadAt(current.row()) answers about the wrong thread for a reply row, because a tree numbers rows per parent. 87 is blocked on 88 and the entry says why: a fix for 87 was written, mutation-checked, shipped and reverted the same evening after it marked an unrelated message read. CLAUDE.md gains the row-number trap as its own entry rather than leaving it implied by the item 20 note, plus the rule that a test for a write path must exercise the reply case: the reverted fix was green because it asserted on a root selection, the one case where row() is correct. The cid-prefixing note is corrected to say every caller now passes one message while explaining why the prefixing stays. The changelog carries a Removed entry and an Upgrading note, including that mark_read_delay_ms accepts a negative value to disable auto mark-read entirely, verified against config.h. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 36, narrow item 66 with a negative resultDanilo M.1-44/+23
Item 36's section moves to the closed file and its three traps go into CLAUDE.md, which is where they will be read: the worker is unreachable by findChild, rowCount on an unexpanded thread row is 0 by design, and currentThreadId reports intent rather than content. The claim that this class of bug cannot be reproduced in test_mainwindow is now false, so it is corrected rather than left standing beside its replacement. One in-test reference to item 36 as a permanent limitation is reworded: bare-window cases still have no worker, but that is now a choice per case rather than a property of the binary. Item 66 stays open with the simple case ruled out. The negative result sharpens this entry's own candidate rather than contradicting it: the test drives setCurrentIndex, which updates the selection model synchronously, while the suspect guard turns on a real click not having done so yet. Two cheaper conditions are named as still unexcluded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 84, spec item 36Danilo M.1-64/+19
Item 84's section moves to the closed file, recording that the fix was a split rather than a suppression, and that its mutation check reproduces the original hang at exit 124 rather than merely failing. Item 36 is specced and no longer "on demand": item 66 needs it. Two findings while writing it. The deliverable is a RED reproduction of 66, with the fix deliberately excluded, since that defect has never been isolated and designing a fix beside a hypothesis is how a wrong one gets locked in. And the item is smaller than it has read since 2026-08-04: wireWorker() already builds the worker from a config key, so a test writes a config pointing at the fixture and nothing in src/ changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 86, excluding a value from a searchDanilo M.1-33/+1
Section moved to the closed-items file on the commit that closes it, per the backlog's own rule. It records the two decisions that are not recoverable from the code, and that the plan under-counted the signature change: three test files drive these signals, not one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs(spec): design for excluding a value from a search, item 86Danilo M.1-31/+10
Two decisions the user made, both recorded with their rejected alternatives so they are not revisited. Excluding from an empty query would mean the whole Maildir minus one value. The menu entry is greyed rather than hidden when the query bar is empty, so the feature stays visible to someone exploring a fresh window, and SearchTerm::exclude returns empty for that case as a second layer against a caller that forgets the guard. The menus cannot see the query bar, so MainWindow pushes the fact down through MessageView::setHasQuery from the textChanged lambda it already runs for the Save button. A callback was rejected as an indirection with one implementation; silently doing nothing was rejected because a live menu entry that does nothing is worse than a greyed one. The backlog entry loses its inline approach and points at the spec, carrying the three constraints that decide whether it can be picked up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs(backlog): record item 86, excluding a value from a searchDanilo M.1-0/+54
The 2026-08-14 reconciliation against the user's notes found one entry with no item here: the right-click search offers "search for this" and "add to search" but no way to add negatively. Cause verified in the code rather than copied from the note. Item 85 shipped the two operations as a single bool, built identically in messageview.cpp:566 and messagedetailsdialog.cpp:92 and branched on in mainwindow.cpp:1654, and SearchTerm has no exclusion form at all. So this is not a missing menu entry over an existing capability; there is no third state for an entry to select. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 85, searching from the message paneDanilo M.1-54/+36
Five surfaces in the message pane offer a search built from what they show, replacing the query or narrowing it. The details dialog became rows along the way, which the user wanted independently of this feature. Item 78 is narrowed to the rule shortcut alone and drops to S: item 85 built the menus and item 81 the seeded dialog, so both halves already exist. Its approach text is corrected too, since it claimed the thread list holds a usable sender and notmuch_thread_get_authors returns a display summary, not an address. Three traps recorded in CLAUDE.md: a modal dialog must close before the action it asked for runs, Qt::RFC2822Date validates the weekday against the date, and every query goes through SearchTerm so five surfaces cannot grow five quoting rules.
11 daysdocs: design searching from the message pane as item 85Danilo M.1-1/+39
Item 78 asked for a tagging rule built from something visible in a message. Brainstorming narrowed it: a saved query can already be promoted to a rule, so the road from "I see something interesting" to "a rule tags it" exists as search, save, promote. Searching is the missing step and the safe one, since a query costs nothing when it is wrong while a rule runs unattended against real mail. The search half splits out as item 85. Five surfaces gain a context menu with Search for this and Add to search: subject, date and From/To/Cc in the header, tag chips, body selection, and every header per message in the details dialog, which is rebuilt as rows rather than one text box. Item 78 stays open carrying the rule shortcut alone.
11 daysdocs: record the modal that hangs test_mainwindow as item 84Danilo M.1-0/+55
Cause verified by attaching gdb to the hung process rather than inferred: showWarnings() raises QMessageBox::warning from the MainWindow constructor, and nothing offscreen can dismiss it, so any config problem in a test's fixture blocks the constructor forever. Not a defect in the application. The modal is right for a person and the code says why; the defect is that a test cannot dismiss it and the resulting failure is a silent hang rather than an error naming the cause. Corrects item 81's closing note, which blamed the missing maildir key itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: close item 81, saved query to tagging ruleDanilo M.1-28/+1
Also records item 83's fix and the warning banner in the changelog, which the earlier commit did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysdocs: spec turning a saved query into a tagging ruleDanilo M.1-38/+25
Item 81. A context-menu action on a stored saved query, seeding the rules dialog with the query and a sanitised id, tags left empty and focused. Three decisions worth the record. The backlog's own proposal, a checkbox in the Save query dialog, is rejected: it would make one dialog write both queries.json and the shared rules.json, and SaveQueryDialog is deliberately pure UI that writes nothing. Generated entries are excluded, since their query is composed from the accounts at runtime and a rule made from one would freeze a snapshot that goes stale when an account is added. And the empty tags are load-bearing rather than an omission: validate() refuses a rule that tags nothing, so the one field the user must supply is the one the dialog opens on. This also turns out to be a single-repo change. The rule it creates is an ordinary one, so mailrules.py is untouched; the backlog's note that item 81 spans two repos was about the file it lands in, not the work. Item 78 becomes a second caller of the same seeded-dialog path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 daysfix(rules): stop a rule with a spaced name from vanishing on saveDanilo M.1-108/+8
A rule named "justeat orders" in the field labelled Name was written to rules.json correctly and then dropped by every reader, because load() required ^[a-z0-9][a-z0-9-]*$ and the save path validated nothing. The rule stayed in the file, invisible in the dialog, never applied by the post-new hook, and the next save from the dialog would have deleted it outright. The asymmetry was the defect, not the pattern. TagRules::validate() is now the single predicate: the dialog refuses to save against it, and load() uses it to repair rather than drop, so a rule that fails is visible and fixable instead of silently discarded. - The typed name is sanitised into an id when the field is committed, so the field shows what will reach the file. uniqueId() suffixes a collision, since sanitising is many-to-one and can manufacture the duplicate that load() then drops. - An already-legal id is never rewritten, including one like "a---b" that sanitising would otherwise collapse. Rewriting valid ids would churn a file mailctl also reads. - A bad id loads repaired, with the warning kept: what is on disk is not what the hook runs until the file is saved back. Deliberately not mirrored into mailrules.py. The hook tags real mail unattended every ten minutes, where silently renaming an id is worse than dropping the rule; the file converges as soon as the dialog saves. No format change, so no version bump and no two-repo commitment. The load warning was not missing: it had been showing "1 rule could not be read and was skipped" on every open, in the same font and colour as the intro prose two lines above it, and read as more explanation. It is now a red banner beside Save, with an icon and a dismiss button, and it says the rules need attention rather than that they were skipped, which is no longer true. Dismissal is per-appearance only; a persistent one would re-hide the problem that went unnoticed for a session. Both new dialog tests were confirmed to fail with the sanitiser reverted, and the banner's styling, position and dismissal each fail under mutation. 20 of 20 suites green, 34 tests in test_tagrules. Closes item 83. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfeat(queries): edit, pin and delete a saved query from the UIDanilo M.1-1/+25
Item 82. Saving a query worked and nothing else did: changing one field meant retyping the whole query under the same name, and deleting one meant editing the file by hand. An action that creates something the UI cannot then change or remove is incomplete, and the user hit it within minutes of the first hand test. Right-clicking a saved query, on its button or its menu entry, now offers Edit, Move to menu / Show as a button, and Delete. Every path funnels through one replaceSavedQuery(), which matches on the name the dialog was OPENED with rather than the one it returns, so a rename replaces the entry instead of leaving the original behind beside a new one, and which merges the stored entry's unknown fields in a single place rather than in three. Delete confirms first: the rule against confirmation dialogs covers tag mutations, which the undo stack can take back, and this writes user config that it cannot. Two cases the item did not anticipate. A generated entry has no query to edit, so the dialog shows its composed query read-only rather than offering a field that changes nothing, and carries `generated` and `flat` through an edit rather than letting it decay into a plain entry holding a snapshot of what it resolved to today. And the overwrite notice had to learn to ignore the entry being edited, since warning that "Inbox" already exists while editing Inbox is noise. This also fixes a defect that predated it and was already reachable from the save path. rebuildSavedQueryRow() called deleteLater() on the old row, which defers destruction to the event loop, so the stale row went on answering findChild() and every lookup after a rebuild reported the state from before the edit. Nothing looked wrong on screen, which is why it surfaced only as three tests failing against a row that had in fact been rebuilt correctly. Five tests, three mutations. Matching on the returned name fails two, never writing the file fails three, and dropping the unknown-field merge fails one. That last one initially proved nothing: it drove UNPIN, which copies the stored entry and so carries `unknown` along by itself, and passed with the merge deleted. It now goes through the edit path with a replacement that has none, which is what the dialog actually returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
12 daysfix(queries): put the Save query button beside the query barDanilo M.1-0/+44
The spec asked for "a Save query button beside the search bar" and what shipped was a menu entry and Ctrl+S. The user went looking for the button where the design said it would be and did not find it. Saving is a thing you decide on while looking at the results, so it belongs where the results came from rather than behind a menu or a remembered chord. The button takes the action through setDefaultAction rather than a second connect, so it inherits the text, icon, tooltip and enabled state and cannot end up offering to save an empty query while the menu entry correctly refuses. The mutation that replaces it with a plain clicked() connection fails the test. Also records item 82: a saved query cannot be edited, unpinned or deleted from the UI. Item 23 specified saving and nothing else, and that is exactly what was built, so the only way to unpin a query is a text editor or retyping it in full under the same name. An action that creates something the UI cannot then change or remove is incomplete, and this was found within minutes of the first hand test. It is filed as a defect rather than an enhancement, and the spec now says so where a reader would otherwise take the design for complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>