summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md9
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md48
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md3
-rw-r--r--src/mainwindow.cpp15
-rw-r--r--tests/test_mainwindow.cpp105
5 files changed, 178 insertions, 2 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b76b5f3..6701fae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,15 @@ point at which they are stable.
## [Unreleased]
+### Fixed
+
+- A query whose result contains the thread already open left the message pane
+ on the placeholder: the card selected, the status bar reporting one thread,
+ and nothing rendered. Running a query blanked the pane but kept the ids
+ naming what it had been showing, so selecting that thread again was read as
+ "already displayed" and never loaded it. This is why an `id:` query copied
+ out of a message's own details dialog produced a card that would not open.
+
## [0.23.0] - 2026-08-15
qtmaildir speaks Italian. Nothing loaded a translation before this release, so
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
index 1704a28..d5f58dd 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md
@@ -5290,3 +5290,51 @@ defect when `QT_TRANSLATE_NOOP` is reverted to `QT_TR_NOOP`.
**Not done:** no language-selection UI, and no second language. `QLocale()`
reading the environment is how a Linux desktop already chooses; add a selector
when there is something to choose between.
+
+## 96. A query returning the thread already on display opens onto the placeholder
+
+**Observed (user, 2026-08-15, with two screenshots):** an `id:` query produces
+exactly one card, the status bar reports "1 thread selected (1 message)", the
+card paints as selected, and the message pane stays on the placeholder. Two
+different messages from two different providers, with and without an account
+selected.
+
+**Filed originally as the unverified half of item 66** and assumed to be the
+same empty-`MessageIdRole` failure. It is not, and that assumption is what kept
+it open: 66's fix was correct and this reproduced against it.
+
+**Cause (verified in code and by instrumented run):** `runQuery()` blanks the
+message pane but leaves `m_currentThreadId`, `m_currentMessageId` and
+`m_currentMessageThreadId` naming the thread that was showing. Both selection
+handlers compare a newly selected row against those to decide whether it is
+already displayed, so a result containing that same thread is recognised as
+"already showing" and `onThreadSelected()` is never called. An instrumented run
+printed `currentThreadId after runQuery: "0000000000000001"` against an
+identical row thread id with the pane on the placeholder.
+
+**Why an `id:` query and nothing else.** The id is copied out of the details
+dialog of the message being read, so that thread is current at the exact moment
+the query replaces the view. Any query returning a different thread hides the
+fault completely, which is why it survived from 2026-08-04 to 2026-08-15 and
+why the first version of the regression test passed against it.
+
+**Four hypotheses eliminated by measurement before the cause was found**, each
+of which looked likely from reading:
+
+- `MessageIdRole` empty on a fresh single-message row. The row carries the id.
+- Account scoping mangling the query. `path:"acct/**" and (id:...)` returns the
+ row, and the user reproduced it with no account selected.
+- `MimeParser` failing on the real file. A probe against it reported `ok true`,
+ 9614 bytes of plain text and 40701 of HTML.
+- The id needing quoting. Both real ids resolve bare and quoted, one of them
+ carrying a `=` before the `@`.
+
+**Fix.** Clear the three ids where the pane is blanked, in `runQuery()`. They
+describe what the pane is showing, so they cannot outlive it.
+
+**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. It asserts the ids are cleared, because
+that is the fix's contract, and then that the pane leaves the placeholder,
+which is what the user sees. Mutation check: reverting the fix fails it with
+"runQuery blanked the pane but still names a current thread".
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 e921e89..c376f6e 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
@@ -130,7 +130,7 @@ taking that too literally.
| 63 | No way to see sent mail, and no filter for it | workflow | M | **done** 2026-08-11; see `specs/2026-08-11-sent-mail-design.md` |
| 64 | The Sync button carries a mailbox icon, not a refresh one | presentation | XS | **done** 2026-08-11 |
| 65 | No full code review and optimization pass | correctness | ? | open, unspecified |
-| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | **done** 2026-08-14, unreleased. Not the blank pane it was filed as: the root rendered the CONVERSATION until the thread had been expanded once, then one message. Now always one message, and the conversation view is removed at the user's request. **One case unverified by hand:** the notes also report a single-message `id:` query whose card would not open, which is the same empty-`MessageIdRole` failure and should be gone; confirm against an `id:` query before treating it as closed |
+| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | **done** 2026-08-14, unreleased. Not the blank pane it was filed as: the root rendered the CONVERSATION until the thread had been expanded once, then one message. Now always one message, and the conversation view is removed at the user's request. **One case unverified by hand:** the notes also report a single-message `id:` query whose card would not open, which is the same empty-`MessageIdRole` failure and should be gone; confirmed 2026-08-15 as a SEPARATE defect with a different cause, see item 96 |
| 87 | Auto mark-read marks a whole thread, including replies never displayed | defect | S | open; measured 2026-08-14. Reachable only after 66 removed the conversation view. Blocked on 88 |
| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | S | open; found 2026-08-14 by shipping a fix that marked an unrelated message read. Row numbers are per parent in a tree |
| 67 | The placeholder pane counts unread, flagged and inbox, but not sent or drafts | information | XS | **done** 2026-08-11, shipped in 0.15.0 |
@@ -160,6 +160,7 @@ taking that too literally.
| 93 | The query buttons are whatever the user pinned, not a designed set of filters | workflow | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-builtin-filters-design.md`. Absorbs item 90. Four built-in filters composing with the account dropdown; the user's own queries unpinned, never deleted |
| 95 | A query in the overflow menu cannot be run | defect | XS | **done** 2026-08-15, unreleased. Pre-existing and not caused by 93: the entry's action owned a submenu, and Qt emits no `triggered` for those, so the connection had never fired. Surfaced because 93 moved every query into the menu |
| 94 | `pinned` has nothing left to decide once the buttons are built-in | maintenance | S | open; **blocked on 93**, and deliberately not part of it. A user-visible removal: the row becomes built-ins only and every saved query lives in the menu |
+| 96 | A query returning the thread already on display opens onto the placeholder | defect | S | **done** 2026-08-15, unreleased. Split from 66's unverified half, which had a different cause. Reproduced from two screenshots after four measured eliminations |
Sizes are rough: XS under an hour, S a sitting, M a session.
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 7ece2f3..2858514 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -2230,6 +2230,21 @@ void MainWindow::runQuery(FlatResult flat, AccountScope scope)
m_messageView->clear();
showPlaceholderPane();
+ // Cleared WITH the pane, not merely alongside it. These three name what the
+ // pane is showing, and both selection handlers use them to decide whether a
+ // newly selected row is already displayed. Left set across a query they
+ // describe a pane that was just blanked, so a result containing that same
+ // thread is recognised as "already showing" and never loaded.
+ //
+ // That is not a corner case, it is the ordinary way an `id:` query is run:
+ // the id is copied out of the details dialog of the message being read, so
+ // the thread is current at the moment the query replaces the view, and its
+ // one card opens onto the placeholder. A query returning any OTHER thread
+ // hides it, which is why it took a screenshot to find.
+ m_currentThreadId.clear();
+ m_currentMessageId.clear();
+ m_currentMessageThreadId.clear();
+
// Undo entries refer to rows that are about to be discarded. The model
// update they invert would be a no-op against the new result set, leaving
// undo half-applied: the database would change and the list would not.
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index c569f05..b96b158 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -82,7 +82,11 @@ class WorkerBackedWindow
public:
/// Builds the database, writes the config and loads it. Check isValid()
/// and error() before constructing the window.
- bool build()
+ /// `accountKey` and `accountMaildir` add one [account.<key>] section, which
+ /// is what makes runQuery() scope the bar's text with scopedQuery(). A test
+ /// that never selects an account can leave them empty.
+ bool build(const QString &accountKey = QString(),
+ const QString &accountMaildir = QString())
{
if (!m_fixture.isValid()) {
m_error = QStringLiteral("fixture directory invalid");
@@ -113,6 +117,12 @@ public:
// went unnoticed as broken once already.
out << "[general]\n"
<< "notmuch_config=" << m_fixture.configPath() << "\n";
+ if (!accountKey.isEmpty()) {
+ // QSettings reads `/` in a section name as a group separator,
+ // so the section is [account.key], never [account/key].
+ out << "\n[account." << accountKey << "]\n"
+ << "maildir=" << accountMaildir << "\n";
+ }
}
file.close();
@@ -188,6 +198,7 @@ private slots:
void aRefreshsBatchesLeaveTheStatusBarAlone();
void selectingAThreadRootShowsItInTheMessagePane();
void anUnexpandedRootRendersOneMessageNotTheConversation();
+ void aSingleMessageIdQuerysCardOpensInTheMessagePane();
void autoSyncIsNotArmedWhenDisabledOrWithNothingPending();
void autoSyncSkipsWhileABackgroundSyncIsRunning();
void aSuccessfulSyncRefreshesRatherThanRerunningTheQuery();
@@ -7184,4 +7195,96 @@ void TestMainWindow::anUnexpandedRootRendersOneMessageNotTheConversation()
"the pane rendered a conversation, not a single message");
}
+void TestMainWindow::aSingleMessageIdQuerysCardOpensInTheMessagePane()
+{
+ // Item 66's unverified half, reported again against 0.23.0 with a
+ // screenshot: an `id:` query in the bar produces exactly one card, the
+ // status bar reports "1 thread selected (1 message)", and the pane stays
+ // on the placeholder.
+ //
+ // The id is shaped like the real one that fails: dots, digits and an @.
+ //
+ // An ACCOUNT is configured and selected, because that is the state the
+ // report was made from and it is the only thing that changes the query:
+ // runQuery() wraps the bar's text in path:"<maildir>/**" and (...).
+ WorkerBackedWindow backed;
+ const QString wanted =
+ QStringLiteral("1786718040388.1f6b48f1-64d1@mail.example.org");
+ QVERIFY(backed.fixture().addMessage(
+ QStringLiteral("acct/Inbox"), wanted,
+ QStringLiteral("A single message"),
+ QStringLiteral("sender@example.org"),
+ QStringLiteral("Fri, 14 Aug 2026 10:00:00 +0200"),
+ QStringLiteral("The only message.")));
+ QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct")),
+ qPrintable(backed.error()));
+
+ MainWindow window(backed.config());
+
+ QLineEdit *queryEdit =
+ window.findChild<QLineEdit *>(QStringLiteral("queryEdit"));
+ QVERIFY2(queryEdit, "no query bar: the window was never built");
+ auto *view = window.findChild<ThreadListView *>();
+ QVERIFY2(view, "no thread list view");
+ auto *model = window.findChild<ThreadListModel *>();
+ QVERIFY2(model, "no thread list model");
+ auto *pane = window.findChild<MessageView *>();
+ QVERIFY2(pane, "no message view");
+
+ auto *accountBox =
+ window.findChild<QComboBox *>(QStringLiteral("accountBox"));
+ QVERIFY2(accountBox, "no account dropdown");
+ const int account = accountBox->findData(QStringLiteral("acct"));
+ QVERIFY2(account >= 0, "the configured account is not in the dropdown");
+ accountBox->setCurrentIndex(account);
+
+ // THE SAME MESSAGE IS READ FIRST, and that is the whole defect. The id is
+ // copied out of the details dialog of a message being read, so the `id:`
+ // query is always typed while that very thread is the current one.
+ // runQuery() blanks the pane but leaves m_currentThreadId naming it, so
+ // when the row comes back onSelectionChanged() compares the two, finds them
+ // equal, and never calls onThreadSelected: nothing is ever loaded.
+ //
+ // A first query returning a DIFFERENT thread passes against the bug, which
+ // is why the earlier version of this test was green.
+ queryEdit->setText(QStringLiteral("tag:inbox"));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+ view->setCurrentIndex(model->index(0, 0, QModelIndex()));
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
+ const QString firstThreadId = window.currentThreadId();
+ QVERIFY2(!firstThreadId.isEmpty(), "the first view never opened a thread");
+
+ // Exactly what the user types, `id:` and the bare id, unquoted.
+ queryEdit->setText(QStringLiteral("id:%1").arg(wanted));
+ queryEdit->returnPressed();
+ QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000);
+
+ const QModelIndex root = model->index(0, 0, QModelIndex());
+ QVERIFY(root.isValid());
+
+ // The row must name the message before the click can be blamed for
+ // anything: an empty MessageIdRole is a different defect and would make
+ // the assertion below true for the wrong reason.
+ QCOMPARE(model->data(root, ThreadListModel::MessageIdRole).toString(),
+ wanted);
+
+ QVERIFY2(pane->showingPlaceholder(),
+ "the pane was not blank to begin with");
+ // The row IS the thread that was showing when the query ran. Asserted so a
+ // fixture change that made them different threads could not quietly turn
+ // this back into the passing test it was before the cause was found.
+ QCOMPARE(model->data(root, ThreadListModel::ThreadIdRole).toString(),
+ firstThreadId);
+
+ // The pane was blanked, so nothing is on display and the window must not
+ // still claim otherwise. This is the fix's own contract: leave it set and
+ // the selection below is read as "already showing" and never loads.
+ QVERIFY2(window.currentThreadId().isEmpty(),
+ "runQuery blanked the pane but still names a current thread");
+
+ view->setCurrentIndex(root);
+ QTRY_VERIFY_WITH_TIMEOUT(!pane->showingPlaceholder(), 15000);
+}
+
#include "test_mainwindow.moc"