diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 40 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-14-search-from-message-design.md | 224 |
2 files changed, 263 insertions, 1 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index ac830fa..f562e97 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 @@ -142,13 +142,14 @@ taking that too literally. | 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13, shipped in 0.17.0. The window-kind question is left open, see the closed-items file | | 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13, shipped in 0.17.0. See `specs/2026-08-13-rule-builder-design.md` | | 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13, shipped in 0.17.0 | -| 78 | No way to build a rule from something visible in a message | workflow | M | open; wants 76 first, so the created rule lands in a form that can hold it | +| 78 | No way to build a rule from something visible in a message | workflow | M | open, narrowed 2026-08-14; the search half split out as item 85, which is the road a rule is made from. Revisit once 85 has been used | | 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13, shipped in 0.17.0. Follows item 76 | | 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand | | 81 | No way to turn a saved query into a tagging rule | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-query-to-rule-design.md` | | 82 | A saved query cannot be edited, unpinned or deleted from the UI | defect | S | **done** 2026-08-13, shipped in 0.18.0. Right-click offers Edit, Pin/Unpin and Delete | | 83 | A rule named with spaces is written to the file and dropped by every reader | defect | S | **done** 2026-08-14, unreleased. The name is sanitised into an id, save validates, a bad id loads for repair | | 84 | A config problem blocks `test_mainwindow` on a modal nobody can dismiss | testing | S | open; measured 2026-08-14, `showWarnings()` calls `QMessageBox::warning` from the constructor | +| 85 | Nothing on screen can be searched for by right-clicking it | workflow | M | open; designed 2026-08-14, see `specs/2026-08-14-search-from-message-design.md`. Split from 78; rebuilds the details dialog as rows | Sizes are rough: XS under an hour, S a sitting, M a session. @@ -557,6 +558,43 @@ here. **Size: S.** The diagnosis is the expensive part and it is already done. +## 85. Nothing on screen can be searched for by right-clicking it + +**Observed (2026-08-14).** The user asked, while discussing item 78, to be able +to right-click a sender, a subject, a date, a tag chip or a piece of selected +body text and be offered a search built from it, both replacing the query bar +and narrowing what is already in it. The narrowing case is the one that decides +the shape: a query returning a thousand threads is refined by adding a second +condition, and today that means retyping a query the user never typed. + +**Cause.** Not a defect, unbuilt. `MessageView` has no context menu on any +surface, and `TagStrip` paints chips with no hit test and no signals. + +**Approach.** Designed 2026-08-14. **Read +`specs/2026-08-14-search-from-message-design.md` rather than planning from this +entry.** + +Three constraints decide whether to open the spec. **This is search only, and +that is the point**: item 78's rule shortcut is deliberately left out, because a +saved query can already be promoted to a rule and searching is the missing step +on that road, as well as the safe one. **The details dialog is rebuilt as rows**, +which the user wanted anyway, and its value labels must stay explicitly +`Qt::PlainText`: the current `QPlainTextEdit` is a deliberate protection against +markup in header values that come from strangers. And **Add to search +parenthesises both sides**, because the bar may hold a hand-written `or` and +`a or b AND c` binds the wrong way, silently widening a search meant to narrow. + +**Constraints.** The thread list is out of scope and the reason is worth +keeping: a row's `authors` comes from `notmuch_thread_get_authors` and is a +display summary reading `Alice, Bob`, so a `from:` built from it matches +nothing. Item 78's own approach line assumed otherwise. + +A mis-quoted query is not an error to notmuch, it matches zero, so the quoting +helper is tested against the constructed string rather than against a provoked +failure. + +**Size: M.** + ## Deferred, unsized, or split out Items noted while triaging but not part of the original list. Same numbering diff --git a/docs/superpowers/specs/2026-08-14-search-from-message-design.md b/docs/superpowers/specs/2026-08-14-search-from-message-design.md new file mode 100644 index 0000000..50b3a6c --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-search-from-message-design.md @@ -0,0 +1,224 @@ +# Searching from what is on screen + +Resolves the first half of backlog item **78**. Depends on nothing unshipped. + +## What this is + +Five surfaces in the message pane become right-clickable. Each offers two +entries: **Search for this**, which replaces the query bar, and **Add to +search**, which narrows what is already there. The value under the cursor +becomes a notmuch query and runs. + +Nothing here writes a rule. + +## What this is not, and why + +Item 78 as filed asks for a **tagging rule** built from something visible in a +message. That is deliberately not this. The user's reasoning, taken as the +decision: a saved query can already be promoted to a rule (item 81), so the +road from "I see something interesting" to "a rule tags it" already exists as +search, save the query, build the rule from it. Three steps, each reversible, +each showing its result before the next. + +Searching is the step that is missing, and it is the safe one: a query costs +nothing if it is wrong, while a rule is handed to a `post-new` hook that runs +unattended against real mail every ten minutes. Building the shortcut before +the road works would put the dangerous end first. + +**Item 78 therefore stays open** after this ships, carrying the rule shortcut +alone. Revisit it once the search actions have been used enough to know which +values are worth promoting directly. + +## The surfaces + +| Surface | Widget | Query built | Offered when | +|---|---|---|---| +| Subject | header `QLabel` | `subject:"..."` | always | +| Date | header `QLabel` | `date:YYYY-MM-DD..YYYY-MM-DD` | always | +| From, To, Cc | header `QLabel` | `from:` / `to:` / `cc:` | single-message thread | +| Tag chip | `TagStrip` | `tag:x` | per chip | +| Body selection | `QWebEngineView` | `"..."` | selection non-empty | +| Every header, per message | details dialog | all of the above | always | + +**From, To and Cc are offered exactly when the header displays them**, which is +when the thread holds one message. The header already adapts this way +(`messageview.cpp:470-489`) and the reason is recorded there: for a real thread +the recipient differs message to message and neither the union nor the +intersection is "the" recipient. The menu must not offer a value the header is +not stating, so it shares the condition rather than restating it. + +For a multi-message thread those three live in the details dialog, where they +are per-message and unambiguous. That is the fallback, not the primary route: a +single-message thread is the common case and reaching its sender should take one +click, not a trip through a dialog. + +**The `+N` overflow chip offers nothing.** It stands for a list, not a tag. + +**The header menu lists the fields; it does not hit-test them.** The header is +one rich-text `QLabel` holding up to four rendered lines, and working out which +line the cursor is on means mapping a point through laid-out rich text, which is +fiddly and breaks as soon as the label wraps. A right-click anywhere on the +header instead offers every field available for that message, each entry naming +its value: + +``` +Search for subject "Invoice 4471" > Search for this +Search for from foo@example.org > Add to search +Search for mail from 2026-08-14 +``` + +The values are kept beside the label as a small list, populated by the same pass +that renders it, rather than parsed back out of the markup. Building the display +string and the searchable value in one place is what stops them disagreeing. + +Listing is also the better affordance: the user sees which fields are searchable +without discovering it by clicking in the right spot. A long value is elided in +the entry's text; the query uses the full one. + +The tag strip is hit-tested rather than listed, because a chip is a discrete +widget-like thing with its own rect and the user is aiming at one. + +## Two operations + +**Search for this** sets the query bar to the term and runs it. + +**Add to search** combines with what the bar already holds: + +``` +(existing) AND (new) +``` + +**Both sides are parenthesised, and this is load-bearing.** The bar may hold a +hand-written query containing `or`, and `a or b AND c` binds as `a or (b AND c)`, +which silently widens the search instead of narrowing it. This is the same trap +the `post-new` hook already handles when it scopes a rule with `tag:new`, and it +is recorded in CLAUDE.md for that reason. + +When the bar is empty, **Add to search** behaves as **Search for this** rather +than producing `() AND (new)`. + +The account scope is not touched. `MainWindow::runQuery()` wraps the bar's text +in the selected account's scope on every run (`mainwindow.cpp:1981-1983`), so a +scope applied here would be applied twice. + +## Quoting + +One helper, `searchTermFor(const QString &)`, used by every surface: + +- trim, and collapse runs of whitespace and newlines to single spaces +- escape embedded `"` +- wrap in double quotes +- cap the length; a multi-kilobyte selection is a mis-drag, not a query +- an empty or whitespace-only result yields no menu entry at all + +**This gets its own tests, and they matter more than they look.** notmuch's +parser rejects almost nothing: CLAUDE.md records that `from:((((` parses cleanly +and matches zero. A mis-quoted query therefore does not error, it silently +returns nothing, and the feature looks broken with no clue why. Body selections +are arbitrary user-chosen prose and can contain quotes, colons, parentheses, +`AND`, newlines, or a pasted log. + +Assert on the constructed string, never on a provoked parser failure. + +## Wiring + +`MessageView` already has the right signal: + +```cpp +void queryRequested(const QString &query); +``` + +It exists for the placeholder pane's helper lines and carries a documented +security gate: a message body is attacker-controlled HTML and can hold a +`qtmaildir-query:` link, so the signal is emitted only when the placeholder is +what is displayed (`messageview.h:140-149`). + +**Reuse it rather than adding a parallel signal.** The menus are application +chrome built by our own code from values we extracted, not links in a rendered +document, so they do not weaken that gate. The gate stays exactly as it is for +links; these are a separate emitter of the same signal. + +`TagStrip` and the details dialog emit their own equivalent. Every one of them +carries a finished query string and nothing else; the panes never touch the +query bar. `MainWindow` receives, sets `m_queryEdit`, and calls the existing +runner, which keeps the account scope and the generation counter working. + +**Extend needs the bar's current text**, which only `MainWindow` has. The signal +therefore carries the term plus which operation was chosen, and `MainWindow` +does the combining. A pane that built `(existing) AND (new)` itself would have +to read the query bar, which is exactly the coupling the existing signal's +comment says to avoid. + +## The details dialog is rebuilt as rows + +Today it is one `QPlainTextEdit` holding every message's headers as flat text, +built inline in a 40-line `showDetailsDialog()` and shown with `exec()` +(`messageview.cpp:500-552`). The user does not like that it appears as a text +box, and a flat text box cannot carry a per-value context menu without parsing +displayed text back into structure. + +It becomes a scrollable column of labelled rows, one row per header per message, +each row holding its own value and its message index. The context menu then +carries the real value with no parsing. + +**One property of the current dialog must survive.** It uses `setPlainText` and +a `QPlainTextEdit` deliberately, and the comment says why: header values come +from strangers, and plain text cannot interpret markup, so there is nothing to +escape and nothing that could render. Row widgets reintroduce that risk, because +a `QLabel` interprets rich text by default and Qt will guess when the format is +`Qt::AutoText`. + +Every value label is therefore **explicitly `Qt::PlainText`**. Not escaped +input into a rich-text label, which is the same protection one mistake away from +failing. + +The dialog moves out of `MessageView` into its own `MessageDetailsDialog` class. +A 40-line inline builder that now grows rows, menus and signals is past what +belongs inline, and the class is separately testable. + +## Testing + +- `searchTermFor` against quotes, newlines, colons, parentheses, `AND`, empty + and whitespace-only input, and an over-long selection. +- Term construction per surface: a tag chip yields `tag:x`, a date yields the + day's range, a subject is quoted. +- **Add to search** parenthesises both sides, and an `or` in the existing query + survives it. Empty bar falls back to replace. +- `TagStrip::chipAt` against the painted rects, including a miss in the gap + between two chips and the `+N` chip yielding nothing. The hit test and the + paint take their geometry from one function so they cannot drift, as + `CardDelegate::expanderRectFor` already does for the expander. +- Header menu contents: From/To/Cc entries present for a single-message thread, + absent for a multi-message one, with subject and date present in both. **The + absence half needs a guard proving the menu was built at all**; item 82 records + that a test asserting only the absence of a widget passes against no + implementation whatever. Asserting that subject and date are still there is + that guard. +- An empty header field yields no entry. A message with no Cc must not offer + `cc:""`, which parses cleanly and matches nothing. +- Details dialog rows carry the right value and message index, and value labels + report `Qt::PlainText`. +- One `test_mainwindow` case that a triggered action leaves the expected text in + the query bar. + +Three traps from CLAUDE.md apply and are not re-derived here: a rendering probe +proves nothing about which widget is clickable, visible and clickable and working +are three separate properties, and a hit test asserted against `visualRect` can +endorse a broken layout. + +## Out of scope + +**Creating a rule directly from a value.** The remainder of item 78, above. + +**Searching from the thread list.** Its context menu is tag actions today and +the row's `authors` is a display summary from +`notmuch_thread_get_authors`, not an address: it reads `Alice, Bob` or +`Alice| Bob`, so `from:` built from it matches nothing. Any thread-list search +needs a real address resolved from a message first. Worth recording because the +backlog's own approach line for item 78 assumed the row held a usable sender. + +**Refining by anything not on screen.** No date-range picker, no "from anyone at +this domain". Both are reasonable and neither is this. + +**The web view's standard context menu entries.** Copy and the rest stay as +they are; the search entry is added alongside. |
