| Age | Commit message (Collapse) | Author | Files | Lines |
|
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>
|
|
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>
|
|
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>
|
|
Ordered so each task leaves the suite green: the grammar first, then
the SearchMode enum alone, then the four signatures as one mechanical
change with no behaviour difference, then the guard, then the menus.
Two traps carried from item 85 into the task that inherits them. The
details dialog's mutation check hangs rather than fails, and a hung
binary is re-run stale by a later ctest. The mutation revert is by hand,
never from a backup file, which silently reverted a fix last session.
Construction order verified rather than left to the implementer:
registerActions() runs after buildUi(), so both pointers are live and
no null guard belongs in the lambda.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
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>
|
|
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.
|
|
Qt::RFC2822Date validates the weekday against the date, so 'Thu, 14 Aug 2026'
parses as invalid: that day is a Friday. Task 2 hit it and Task 6 carried the
same wrong data. The failure is indistinguishable from the timezone-comment
trap the date parse exists to handle, so the plan now names it.
|
|
Eight TDD tasks. SearchTerm and the date parse first, since both are pure
logic testable without a widget; then one surface per task, the window's
wiring, and the suite check.
Two findings while planning, both now in the spec. The Date: header parse
already exists inside a file-local function in mimeparser.cpp, complete with
the fix for Qt::RFC2822Date rejecting a trailing timezone comment, so it is
extracted rather than rewritten. And queryRequested is the right precedent
but the wrong signature: these actions carry a replace-or-narrow flag, so a
second signal sits beside it and the placeholder links keep their gate.
|
|
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.
|
|
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>
|
|
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>
|
|
Six tasks, TDD, one commit each. Two things the spec did not anticipate
and the plan has to carry: the rules dialog is non-modal and
single-instance, so a second request must seed the dialog already open
rather than being dropped, and the seeded id is uniqued against the
working list rather than the file, since that list can hold unsaved
rules whose ids collide just as hard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
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>
|
|
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>
|
|
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>
|
|
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>
|
|
Item 23 had grown past what a backlog entry should hold: a storage format, a
migration, a dialog and a layout change. This document's own rule says a fully
specified item moves to specs/ and leaves behind the two or three things that
decide whether it can be picked up, the way items 53, 63 and 76 went. The entry
now carries the observation, the three deciding constraints and the relation to
item 10, and points at the spec for the rest.
The spec pins what was still loose. The JSON is an ordered array, since the
ordering is the whole reason for moving off [queries], and nothing may sort it
on load. A query's account scope stores the account KEY, the INI group suffix,
rather than the maildir path, so it does not duplicate config that already lives
in one place and go stale when the user edits it; the scope then composes
through Account::scopedQuery(), whose parenthesisation is load-bearing for the
same reason it is in the rules hook, an unparenthesised disjunction escapes its
scope and matches every account.
Migrated entries are pinned, so the query row does not silently empty on the
first run after upgrade, and migration order is alphabetical because that is
genuinely all the INI knows. Sent stays out of the file: it is generated from
allSentQuery() rather than stored, and folding it in would mean writing a
per-account path query into stored config, which is the duplication the
account-key decision just rejected.
The testing section is written against the traps already recorded in CLAUDE.md.
The migration test asserts the INI file is byte-identical rather than re-reading
it through QSettings, which would pass against a rewrite that preserved values
while dropping comments; the round-trip test asserts order, which is the
property the INI could not provide; and the dialog is left to a hand test,
because the offscreen platform cannot assert sizing at all and a Cancel goes
through done(int) rather than closeEvent.
Every code reference in the spec was checked against the files rather than
copied from the entry.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The user proposed managing saved queries the way the tagging rules are managed,
in a JSON file rather than in the INI. It is a better answer than either option
the entry had been weighing, a per-query pinned flag or a [general]
pinned_queries list, because those each solved one problem and this solves
three.
Order is the one that could not be solved any other way. [queries] is read
through childKeys(), which returns keys alphabetically rather than in file
order, so the saved-query buttons appear alphabetically today and there is no
way to arrange them; config.cpp already carries a comment saying a hand-rolled
parser would be needed to change that. A JSON array is ordered intrinsically. On
top of that the document has room for the pinned flag the two tiers need, and
for the per-account scope the save dialog wants, which SavedQuery has nowhere to
put: it is {name, query} and nothing else.
The entry takes the shape of rules.json but explicitly not its machinery.
rules.json is JSON because two independent implementations have to agree on it,
this repo and mailctl's mailrules.py, and the unknown-field preservation and
version handshake exist to keep them from destroying each other's writes.
Queries have one reader, so only the versioned-document-with-unknown-fields part
is worth carrying over.
Migration reads [queries] once when queries.json is absent, writes the JSON, and
leaves the INI section in place. Stripping it would mean rewriting a hand-edited
file with QSettings, which drops comments and key order across the whole file
and is the exact loss this decision was made to avoid; leaving it costs a few
stale lines and keeps a downgrade working. Reading both forever was rejected as
two sources of truth for one thing.
This also retires the open question the entry had carried since 2026-08-04,
where the write should go. Both of the original answers were poor, one
machine-writing the user's hand-edited config and the other filing user intent
as window state under ~/.local/state. A machine-written JSON document beside the
hand-written INI is the cleaner split, in ~/.config so it lands in a config
backup.
Two constraints recorded that the build would otherwise meet late. startup_query
names a saved query and must keep resolving, and its "first entry" fallback
quietly changes meaning from alphabetically-first to first-in-the-user's-order,
which is user-visible and belongs in the changelog. And the README documents
[queries] in three places, one of which explains the alphabetical button order
this change removes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 23 said only that a query could not be saved from the UI, and left the
presentation as a one-line sketch. The user described what they actually want:
a Save query button beside the search bar, opening a dialog that takes a name
and an account scope; saved queries split into two tiers, with a few kept
visible as buttons and user-made ones behind a menu; and the button row moved
onto a row of its own once it no longer holds everything.
Two things came out of writing it down. There is no built-in default query set
in the code at all: every entry in [queries] is user-written and renders
identically, and Sent is the lone exception because it is built from
allSentQuery() rather than living in [queries]. The two tiers therefore need a
mechanism that does not exist yet, either a per-query pinned flag or a
[general] pinned_queries list, and that config format choice is the one decision
left on the item. The design also settles the question the entry had left open:
a query the user named and scoped in a dialog is intent rather than machine
state, so it goes in qtmaildir.conf beside the hand-written ones, at the cost of
QSettings reformatting a hand-edited file on first save. The README has to say
so.
The save-as-a-filter half is split out as item 81. A saved query is a view and
costs nothing if it is wrong; a rule is applied to real mail by the post-new
hook every ten minutes and lives in the rules file that this repo and mailctl
implement independently. Folding it into 23 would make a presentation change
carry a two-repo commitment, so 23 can now ship without it. Item 81 records the
constraints it will hit: a stored query carries no scope and the hook
parenthesises it, which matters more here than usual because a query saved for
a view is often a disjunction, and the hook refuses to remove unread or inbox,
so the dialog must say so rather than failing silently.
Item 23's relation to item 10 firms up as a result. Account scope in the save
dialog answers item 10's remaining half as a side effect, so the entry now says
that outright, and says not to reopen item 10 to do it: the user postponed it
and asked that the rest not be proposed unprompted.
Also corrects a stale reference. The entry claimed a SavedQueryBar class shows
the saved queries; no such class exists or ever has, and the buttons are built
inline in MainWindow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 73. The backlog kept every item's full Observed/Cause/Approach section
forever, including the sixty-eight that are closed, and had reached 5056 lines:
past the point where it could be read in one pass, and past the point where a
tool could open it at all.
The closed sections move to 2026-08-03-post-0.1.0-usability-closed.md, taking
the backlog to 570 lines. The status table stays where it was and remains the
index of all 80 items, so a closed item keeps its row, its date and its outcome
beside the open ones; only its evidence moved. Nothing was renumbered and
nothing was deleted, which the item required: the numbering is cited from commit
messages, from CLAUDE.md and from the specs, and both files share one sequence,
so item 42 is `## 42.` in whichever file holds it.
The split was done by script and verified by set difference rather than by
reading: every non-blank line of the original appears in one of the two files,
zero missing, and the only lines not in the original are the new file's header.
All 80 numbers resolve, every open item has its section in the backlog, every
closed one in the archive, with no duplicates and no orphans.
Two things the item's own approach did not anticipate. Three cross-references
said "see below" and their targets had just moved, so rows 60 and 75 and the
header's note on item 20's parked branch now say where the entry went. And the
cause was never the fifty done sections, it was that nothing moved a section on
the day its item closed; doing this once buys a few months and then item 73
returns. The rule in "Adding to this document" now requires the move on the
closing commit, and CLAUDE.md tells a future session that grepping the backlog
for a closed item's evidence will find the table row and nothing else.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 77. The dialog could say how many messages a rule matched and not
which ones. A Preview in list button now runs the selected rule's query
in the main window; the dialog stays open, since comparing the rule
against its results is the point.
Two constraints from the backlog entry, both now asserted and both
mutation-checked.
The query runs exactly as stored, with no tag:new and no wrapping
parentheses. The post-new hook adds those when it applies a rule, and a
preview that copied them would match nothing outside a sync window,
since tag:new is set only on mail that has just arrived.
The account selector is cleared first. runQuery() wraps the bar's text
in the selected account's scope, and a rule query usually names its own
path already, so previewing one with an account selected would scope it
twice and show an empty list, which reads as "this rule collects no
mail".
The second mutation only fails once the test's config has an account to
select: with the default empty config the selector sits on "All
accounts" anyway, and asserting that a preview leaves it there passed
against the mutation. Recorded in the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 80. A rule with eight From conditions left the list showing about
one and a half rows.
The list was added with stretch 1 and the form below it with none, which
looks decisive and is not: a stretch factor only distributes space above
each widget's minimum, and the form's minimum grew with every condition
row, so each row came straight out of the list. The builder asked for
120px with one row and 414px with eight.
A QSplitter now divides the list from the editor, so the balance is the
user's and is saved beside the column widths, and the condition rows sit
in a QScrollArea capped at 190px so the editor cannot grow without bound
however the splitter is set. The scroll area is what text mode hides;
hiding the builder inside it would leave an empty frame.
Three measures were tried in the test before one told the bug and the
fix apart, and two passed against broken code: the dialog's
minimumSizeHint does not track form rows and read 580 either way, and a
qMin against the scroll area's own hint read small whether or not the
cap was set, since an uncapped maximumHeight is QWIDGETSIZE_MAX. What
survives mutation is the editor pane's minimum inside the splitter, plus
the cap read directly, and both are asserted. A row's size hint is
invalid until the event loop runs, so the test calls processEvents after
selecting a rule or it measures the same height twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 75 shipped claiming the rules window remembers its size. It does
not, and no code here can make it.
Hyprland tiles the window to fill its slot, so the size dragged belongs
to the tile. saveGeometry stores frameGeometry beside normalGeometry and
restoreGeometry restores the normal one, which stays at whatever
resize() last set it to. Decoded from the real state file after a hand
test: frame 2248x806, normal 760x664. The dialog restores 760 correctly
and still opens tiled.
Three diagnoses were tried before this one and each was disproved by a
probe rather than argued away: that restoreGeometry rejected the blob as
off-screen, that the layout overrode a geometry applied before the first
show, and that a test could tell the broken and fixed versions apart.
The last one matters most: the offscreen platform returns an identical
frame for both, so a size assertion passed against the bug and a
mutation restoring it left the suite green. That assertion is not
reinstated.
The column widths, which are what actually works, keep their test. The
changelog and the backlog entry are corrected to say what ships, and
CLAUDE.md gains both the tiling-compositor trap and the rule that the
offscreen platform cannot test window sizing at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The geometry was saved from closeEvent, and neither dialog button sends
one: Cancel calls reject(), Save calls accept(), and only the window
manager's X button produces a QCloseEvent. So the size and the column
widths were kept for the one route out of three that a user almost never
takes, and a resize followed by Cancel came back forgotten.
The save moves to a done(int) override, which both buttons funnel
through and which QWidget::close() also reaches.
The test that covered this passed against the bug because it asserted
with close(). It now drives all three routes rather than trusting one to
stand for the others, and shows the dialog before the close leg:
close() on a widget that was never visible returns early without
reaching done(), so that assertion would otherwise prove nothing.
Both traps recorded in CLAUDE.md, since neither is specific to this
dialog.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 75. saveGeometry() and the rule list header's saveState() go to
uistate.conf under keys of their own, written on closeEvent so a size
survives Cancel as well as Save. The 760x520 resize stays as the
first-run fallback.
The backlog's approach was wrong on one point and a test caught it. It
said to drop the resizeColumnToContents calls once a saved header state
exists, which fixes the restore and leaves the original defect standing:
with nothing saved, a width the user had just dragged was still
discarded by the next add or delete. Each column is instead auto-sized
once, on its first fill, after which the width belongs to the user
however it was set. Two flags, because the count column is filled later
by a reply from the worker.
The window stays a QDialog. Making it a top-level window needs the
unsaved-edit story that being modal currently sidesteps, and that is its
own decision rather than part of this item.
Both tests redirect XDG_STATE_HOME as well as XDG_CONFIG_HOME, so they
cannot write the real uistate.conf. The geometry is asserted on the
stored value rather than the reopened frame, per item 46: the offscreen
platform does not honour a resize.
Also corrects setFolders' doc comment, which still described the folder
list as coming from Config.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Ticking "Edit as text" was a one-way trip: the only way back to the rows
was closing the dialog and reopening it. The checkbox was parented to the
builder widget and sat on the match row, and switching to text mode hides
that widget, so the toggle disappeared along with the rows it governs.
Move it to the query row, which is visible in both modes.
The existing tests all passed against this, because they drove the toggle
through setChecked and then asserted on the checked STATE. A hidden
checkbox reports its state perfectly well, so every one of those
assertions held while the widget was unreachable. The new test asks the
question that matters, whether the toggle would be on screen, and it uses
isVisibleTo since nothing is isVisible on a dialog that was never shown.
Worth recording how close the mutation check came to endorsing this too.
Reparenting the checkbox alone left it in the query row's layout, so it
stayed visible and the test still passed. Only restoring the full shipped
shape, parent and layout together, reproduced the fault and failed the
test. A mutation that does not reproduce the original bug proves nothing
about the test that is meant to catch it.
The spec's layout sketch carried the same error and is corrected, with
the reason, so the next reader does not reintroduce it.
|
|
The first pass of the field repair restored the query and the tags and
stopped there. The note was also blank, which the user noticed: every
sibling account rule carries an identical note and only the damaged rule
had none.
The reason it was missed is worth keeping. The shell backup was read for
the tagging command, and the note comes from the comment block above it,
which the migration had given to all five account rules alike. The
handler at fault writes every field of a rule, so every field is equally
exposed, and a repair that checks only the fields that first drew
attention will leave some of the damage in place.
Restored from the four siblings, which are byte-identical, and the whole
file re-audited: no rule now has an empty query, id, note or tag list.
|
|
Opening the tagging rules dialog and pressing Save destroyed the first
rule in the list, without any editing. The rule lost its query and its
tags, then vanished entirely on the next load, since a rule with an empty
query is dropped as malformed.
Reproduced against the released tag rather than the branch, in a
throwaway worktree at 9585674 with a two-rule fixture: constructing the
dialog and running its save path left one rule of two.
onSelectionChanged blocked signals for the note widget only, while
m_enabled::toggled two lines later reached applyEditsToCurrentRule, which
writes every field from widgets the loader has not filled yet. The
existing comment there shows the hazard was known for one widget and not
extended to the other.
The fix landed with the builder work: the reloading flag now covers the
whole load, and switchingRulesDoesNotLeakRowsBetweenThem is the
regression test, verified by mutation to fail without the guard.
The live rules file had one casualty, the account rule sitting first in
the list, with both its query and its tags empty while every sibling was
intact. Restored from the shell backup that the earlier migration kept
and verified through mailctl's own reader. The rule had stopped tagging,
but only one message had arrived meanwhile; that message is now tagged
and the account is complete again at 14969 of 14969.
|
|
The spec listed from:(((( among the queries the parser must reject, and
the plan's Task 6 asserted that rejection. Probing the built parser shows
it accepts the query as a From row whose value is the literal text, and
compiles it back byte for byte.
That is correct behaviour, not a leak in the strictness rule. notmuch
reads those parens as characters to search for rather than as grouping,
so the query is meaningful and the row displaying it tells the truth.
Rejecting it would buy nothing and would push a representable rule into
text mode.
The distinction the documents were missing: a parenthesis inside a VALUE
is not a shape question at all, only a parenthesis in grouping position
is. Restate both documents accordingly, and replace the assertion with a
round-trip one, which is the property that actually matters here.
|
|
The draft compile() quoted every Is/IsNot term, which contradicted the
same task's own assertion that a negated tag compiles to .
The implementer resolved it in the direction the tests specify, and the
resolution is right: notmuch reads tag:inbox and tag:"inbox" identically,
counting 5322 either way against the live index, so quoting a tag would
change the stored string without changing what it matches. That breaks
the byte-for-byte round trip this type exists to guarantee.
Restate the comment as the rule rather than as a note about what a test
expects, correct the plan's draft so the remaining tasks do not inherit
the contradiction, and warn the parser task that a quoted tag must not be
read back as a quoting operator.
|
|
Twelve tasks against the design approved today, TDD throughout: RuleQuery
comes first as a value type with no widget dependency, tested on exact
strings, and the dialog is wired to it only once parsing and compiling
round-trip.
Two tasks carry the guarantees the design was shaped around rather than
merely testing behaviour. Task 7 round-trips every query shape present in
the live rules file and pins it with a mutation check, since a compile
that differs by one paren would rewrite a file a second tool reads. Task
6 pins whole-query rejection, because a parser that salvages the part it
understands is how a not clause goes missing and a live mail filter
silently widens.
Queries in the tests are generic placeholders. The shapes are what is
under test and they survive substitution intact.
|
|
The standing backlog reconciliation found four unrecorded entries in the
user's notes, all fallout from item 44's rules dialog now that it is in
daily use: the window forgets its geometry and column widths (75), every
field is free text (76), a rule cannot be previewed against the thread
list (77), and there is no way to build a rule from something visible in
a message (78). Each cause is verified in the code rather than copied
from the note.
Item 76 then went through a brainstorming pass and has a design. The
shape is Thunderbird's filter window, which the user supplied as the
reference: field and operator dropdowns, +/- buttons per row, an all/any
radio, and a separate "but not" block.
The structural point is that Thunderbird owns its filter format and this
project does not. The storage is a notmuch query string shared with
mailctl and executed by the post-new hook, so the builder is a view over
a string rather than a store. That decides the rest: the stored format
is untouched and this stays a single-repo change; a query the builder
cannot represent still opens, saves and runs, in a text mode every rule
carries; and the string is rewritten only when the rows actually
changed, compared against the parsed value rather than tracked with a
dirty flag, which Qt sets during programmatic population.
Measured against the seventeen rules in the live store, sixteen are flat
and one nests an or group inside an and chain, which is what the
exclusion block exists for. The parser is strict by design: it
recognises a query whole or rejects it whole, because a lenient parser
that salvages what it understands is how a not clause gets dropped and a
filter silently widens.
|
|
Item 61. An init() fixture gives every test its own empty lock table in a
QTemporaryDir, so no test observes the machine's real sync state. The
failure was never intermittent in the usual sense: 0 failures in 30 runs
with no lock held, 30 in 30 with one held. It presented as three tests
failing that never mention syncing, and cost three misdiagnoses.
The three tests that already used the seam each restored "/proc/locks"
when finished, which was itself the defect: it handed the real table to
whichever test ran next, so one test opting in re-exposed all the others.
Those restores are gone and cleanup() leaves the temporary path in place.
noTestCanSeeTheRealLockTable guards the fixture, since a silent revert
would go back to failing for reasons no assertion mentions.
Verified with the lock deliberately held: 3 failures before, 119/119
after, full suite 19/19. Mutation-checked by disabling the fixture, where
the guard fails first and a real test fails behind it.
|
|
The tagging rules moved from the shell post-new hook to a shared JSON
store both qtmaildir and mailctl read. Seventeen real rules were
converted, each keeping its shell comment as a note, and the conversion
was proved against the real index before anything was installed.
Four findings are recorded in CLAUDE.md rather than only here, because
they will outlive the item: a stored query carries no scope and the hook
parenthesises it (a disjunction would otherwise escape tag:new and match
everything); notmuch's parser rejects almost nothing, so a test asserting
a provoked query failure fails against correct code; rule counts must
count messages rather than threads; and a count request must not bump the
query generation, which would blank the message pane.
|
|
Fifteen tasks across two repositories, TDD throughout. Tasks 1-9 build
the format, the post-new hook and mailctl's read-only rules command;
10-13 add qtmaildir's reader and dialog; 14 migrates the real rules and
15 closes the backlog item.
One deviation from the spec, recorded at the top of the plan: the spec
called for a countRules worker slot, but requestCounts already exists
and counts threads. A tagging rule tags messages, so the plan adds a
general requestMessageCounts instead of a rule-specific slot.
|
|
Item 44 sat as "open, unspecified" because nothing in this application
applies rules at sync time, and the item could not be planned until it
was known whether such rules existed anywhere. They do: the notmuch
post-new hook holds hand-written `notmuch tag` lines scoped to tag:new,
carrying their reasoning in shell comments.
The design moves them to a tool-neutral JSON store that both qtmaildir
and mailctl read, with unknown fields preserved across a write by either
tool so neither owns the format. A rule carries no scope, so the same
rule serves the hook, a dry run and a future backfill.
Also in this pass:
- Item 61's cause is established, not open. It is the user's cron sync
holding the mbsync lock: 0 failures in 30 runs with no lock held, 30
in 30 with one held. The fix is item 38's existing seam applied across
the suite. The document still said "not established" and proposed a
load hypothesis that had already failed to reproduce.
- Item 74 records the first-start latency measured this session. The
delay is the notmuch index paging in from disk, 5714 ms cold against
154 ms warm for the same 4444-thread query, and is not addressable
here. What it did expose is a real defect: the status bar holds
"Searching..." for the whole walk while rows are already arriving.
|
|
Items 70 and 69, the second folded into the first as item 70's own size note
predicted it should be.
The panes drew their state marks as font glyphs: U+1F4CE for an attachment and
U+2605 for a flagged thread, each with a fallback for a font that cannot render
it. Both fell back to "*", so on such a font a flagged thread and one carrying
an attachment were indistinguishable, which is a defect the fallback introduced
rather than prevented. What a mark looks like was also the desktop's decision
rather than this application's, and the panes are exactly where it should not
be: the user asked for the toolbar and menus to keep following their icon theme
while the panes stop.
Six marks now ship in assets/icons/marks/: flagged, attachment, passed, replied
and the two expander triangles. QIcon::fromTheme still resolves every toolbar
and menu icon and was not touched.
Licensing chose the shapes. The look came from a GPL3 icon theme, and this
project is GPLv2-only, which are incompatible: GPLv2's "no further
restrictions" clause bars shipping GPL3 assets in a v2-only work. The six were
drawn fresh in the same idiom instead, with no path data copied. The idiom is
generic: solid single-path silhouettes at 16x16 with no strokes.
They are compiled in as string literals rather than loaded from a .qrc.
src/CMakeLists.txt already records why resources belong to the executable: a
qrc in the static library registers itself from a global initialiser the linker
drops. The tests link the library, so a resource-based mark would be missing
exactly where it needs asserting. assets/icons/marks/ stays the editable
source.
One asset serves both palettes. Every payload paints with fill="currentColor",
which QSvgRenderer renders black rather than resolving, so Marks::pixmap
composites the wanted colour with CompositionMode_SourceIn. A mark then takes
the card's own pen colour and follows selection and the read/unread dimming
without a second variant to keep in step.
CardLayout reserves a rect per mark and CardDelegate paints into it. The marks
were glyphs inside the subject STRING, so their width came free from the text
metrics; as icons the geometry has to know they are there or the subject runs
underneath them. The expander pill had the same trap, its triangle being a
glyph in expanderLabel(), and now reserves that width explicitly.
Item 69's part: passed and replied were words in the tag strip and are marks
beside the subject now. The message pane's header carries the flagged and
attachment marks next to the subject, per the user's decision that the right
pane needs those two and only outside the message area.
A duplicate that no test caught is worth recording. Every geometry assertion
passed while a card showed passed as BOTH an arrow and a green tag chip: the
chip filter had no reason to know a mark had appeared. It was found by
rendering real cards to an image and looking at them. isDrawnAsAMark() is now
one list consulted by both PillTagsRole and MessageOwnTagsRole, since two
copies drifting apart is how a tag ends up drawn twice on one row and not at
all on another.
Fourteen tests: nine in test_marks, four in test_cardlayout, one in
test_threadlistmodel. Mutation-checked at four points, each failing a test: the
subject ignoring the marks, the flag not indenting the subject, the pill
forgetting the triangle's width, and the recolour composite removed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Item 71. A tag edit reached the notmuch index at edit time and then sat there
until the user clicked Sync or their cron job fired, so "mark all read" updated
the view while the change itself waited, sometimes for ten minutes.
A confirmed edit now arms a debounce that runs the existing sync path. The delay
is auto_sync_delay_ms in [general], defaulting to 2000, and follows
mark_read_delay_ms exactly, including that zero and negative are not errors:
zero syncs on the next trip through the event loop, and any negative value
disables the behaviour, which is the switch for a user who wants only their cron
job.
It is armed from onTagsApplied, where a write is confirmed and the pending count
is already current, rather than where one is sent: a sync scheduled for a write
the worker went on to reject would run for nothing. A debounce rather than a
schedule, restarted by each edit, because "mark all read" confirms one write per
thread in the view and an arm-per-edit timer would be the storm of syncs the
debounce exists to prevent. Nothing is armed when no sync command is configured
or when the pending count is zero, the case where an edit was netted against its
own inverse. When the timer fires with a sync already running, local or cron, it
skips rather than queues: mbsync's own answer to a second run is to fail on it,
and the edits stay pending rather than being lost.
Also fixes a pane blanked out from under the reader, found by hand testing this
feature. onSyncFinished called runCurrentQuery() where the cron path calls
refreshCurrentQuery(), and a re-run clears the model, the undo stack and the
message pane. The stale-thread notice handles a thread that stops matching the
query and has since item 35, but a re-run left nothing for it to describe. The
two paths had no reason to differ; before this item a local sync only followed a
click on Sync, so the difference went unnoticed. Reading a message in the Unread
view, having it marked read, and watching the pane go blank two seconds later is
what surfaced it.
Its test asserts on the undo stack rather than the pane: both paths issue a
queued query test_mainwindow has no worker to answer, so the pane ends up blank
either way and an assertion on it would pass against both, while the undo stack
is cleared by one and kept by the other.
Nine tests, four in test_config and five in test_mainwindow, each
mutation-checked: removing the schedule call, honouring a negative delay,
dropping the nothing-pending guard, dropping the already-running guard, and
restoring runCurrentQuery() each fail a test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
The session's backlog reconciliation against the user's notes found nothing
unrecorded, but did find item 67's status cell still reading "open" after the
work shipped in 0.15.0 (72812c0).
The section's Approach also proposed tag:draft as the obvious drafts query,
which the implementation rejected: it counts 0 against the real database and no
draft-ish tag exists there at all, so a tag-based line would have been a
permanent zero that reads as working code. Both lines are folder-composed
instead. Recording that, since the Approach as written would otherwise send the
next reader down the path it was already measured out of.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Eight entries from the notes had no item here. Appended as 66 to 73 with
each cause verified in the code rather than copied from the note: a blank
message pane on a first click (66), missing sent and drafts counts (67),
a forwarded-subject tag (68), passed and replied as words rather than
glyphs (69), pane icons against system icons (70), no sync after a
toolbar action (71), khard/khal (72), and this document's own size (73).
Item 68 arrived as "the passed tag appears for Fwd: but not Fw:, expand
it". Measured against the real database, that correlation does not
exist: 6 messages carry the tag in total, 194 Fwd: subjects carry none,
and every tagged message has P in its Maildir flags. The tag is the
Maildir P flag translated by notmuch under maildir.synchronize_flags,
written by whichever client forwarded the message. Nothing anywhere
reads a subject line, so there is no rule to expand. The section now
carries the measurements and costs the two real options, a display-only
mark against writing the flag out to 222 messages, and stays open
pending that decision.
|
|
Adds a `sent` key to [account.*] naming that account's sent folder, and a
Sent button beside the saved queries that composes its query from every
account carrying one. An account without the key is omitted silently, as a
real account may keep no sent mail locally. With no account selected the
button spans all of them; selecting one narrows it through the existing
scope wrap rather than a second path.
Composed at run time rather than shipped as a [queries] entry. A saved query
is one fixed string: it cannot narrow to the selected account, and it goes
stale the moment an account is added or a provider renames a folder.
The design and the measurements behind it are in
docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are
worth repeating here.
The composed path is QUOTED, and that is load-bearing. A real provider nests
its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax:
unquoted, the query parses rather than matches and returns nothing while
looking entirely plausible. Composition happens in one place so there is one
chance to get it right, and a bracketed path is pinned in a test.
Recipients are opt-in per query, which is a performance contract rather than
a preference. notmuch_message_get_header(m, "To") is not served from the
index, it reads the message file: folding every thread of a 4411-thread
inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The
worker skips the walk entirely unless asked, and the refresh path carries the
same flag so a background sync cannot blank the column mid-read. Always
folding is mutation-tested: the data would be right and only the cost wrong,
which nothing else here would notice.
The messages reached through the thread are owned by it and freed with it, so
recipientsOf() holds them raw and finishes while the thread is alive, exactly
as walkReplies does. An NmMessage wrapper there is a double-free.
Sent mail is presented flat, and the pane follows. A message you sent
otherwise drags in the replies you received, so a view labelled Sent shows
conversations rather than what you sent. ThreadListModel::setFlatMode() makes
hasChildren() and ReplyCountRole answer differently and changes nothing else;
runQuery() sets it on EVERY run, so any other query restores the tree on its
way through and the flag cannot outlive the button that set it. The pane
needed its own fix for the same reason: the single-message path depends on a
field only filled when a thread is expanded, which never happens in a flat
list, so loadThread() gained matchedOnly and drops the messages that did not
match instead of rendering them as stubs.
Recipients replace the sender through the existing SendersRole rather than a
new one, so the delegate needs no branch and cannot disagree with the model
about which name a row shows. It falls back to the sender when a To header is
absent or unparseable, since a blank where a name belongs reads as a
rendering fault.
Address parsing uses GMime: a display name may contain a comma, so
"Rossi, Mario" <m@example.org>, info@example.net is two addresses and
splitting reports three. internet_address_list_parse returns NULL for an
empty string, which is a crash if unguarded.
Backlog item 63.
|
|
The backlog entry could not be planned because it turned on a fact about the
user's mail rather than a design choice. Measured against the real database: no
`sent` tag exists at all, so the XS branch is dead. Every account keeps sent
mail in a folder, and the folders disagree across three shapes, with one
account having no sent folder whatsoever. That rules out a `<maildir>/Sent`
convention and forces a per-account key.
Decisions recorded: a per-account `sent` key composed at run time rather than a
shipped [queries] entry, which cannot narrow to the selected account and goes
stale when an account is added; an account without the key omitted silently, as
`drafts` already is; all-accounts combines and one account scopes through the
existing wrap; and recipients on the card in a Sent view, since the sender is
the user on every row.
Three constraints came from probing rather than memory. A bracketed provider
path contains `[` and `]`, which are Xapian syntax, so quoting is load-bearing
and the composed query returns its real count only because each path is quoted.
notmuch has get_authors and no recipients call at any level, so the To summary
must be folded per message in the worker, under the same thread-ownership rule
as walkReplies. And GMime's internet_address_list_parse returns NULL for an
empty string, which is a crash if unguarded; it also parses a comma inside a
quoted display name correctly, so comma-splitting is out.
Size revised M, up from S: the recipients half is a new ThreadSummary field, a
worker-side per-message walk, the first GMime address parsing in this codebase,
and a card that has to know which view it is in.
The spec lives in its own file rather than inline. The backlog is past four
thousand lines and its job is to say what is open and how big, which a hundred
lines of design buries. The entry keeps the finding and the size and points at
the spec, following item 53. "Adding to this document" now states the split
rule so the next specified item does not land inline again.
Account keys and folder names in the spec are placeholders. The counts and the
folder shapes are the real measurement and are what the design turns on.
Backlog item 63.
|
|
Adds [general] date_format, a QDateTime pattern for the date a thread card
shows. Absent or empty means the system locale's short format, which is what
every other application on the desktop uses and stays the default.
The format reaches the LAYOUT, not only the painter. CardLayout::compute()
reserves the date's width from widestDateSample(), so a pattern that arrived
only at the drawText call would be elided into a rect sized for the old
format, which is the clipping the bold-font fault already produced once. It
rides on CardLayout::Input and defaults to an empty string, leaving every
existing call site unchanged. Confirmed by mutation: making the width ignore
the format fails the test.
widestDateSample() memoised its result in a static, which would have sized
every format after the first from whichever arrived first. It is a plain call
now, costing one QLocale lookup per row, the same as formatting the date.
Validation rejects only a pattern whose output is CONSTANT, found by
formatting two different instants and comparing. QDateTime::toString() treats
nearly every letter as a field, so "banana" formats as "bpmnpmnpm" and
"hello" as "22ello": nonsense, but they vary with the instant, and a check
claiming to find "no date field" cannot reject them. What harms the user is
the pattern that prints the same text on every card, and that is what is
refused, with the value named in the message.
The model supplies the pattern through DateFormatRole for the same reason it
supplies the tag colours: it is the one object here holding config, and a
delegate reading config itself would be a second source of truth.
Backlog item 62.
|
|
The Sync button used mail-receive, a mailbox glyph, which reads as "mail"
rather than "fetch again". The toolbar follows the desktop's tool button
style, so on an icon-only desktop the icon is the whole control and has to
carry the meaning by itself.
view-refresh is the standard freedesktop name for the action. The existing
noTwoActionsShareAnIcon test covers the collision risk that the 0.12.0
Archive/Mark-all-read defect came from, and passes.
Also records the backlog reconciliation this came from: items 64 and 65,
appended from the user's notes with their causes verified in code. 65 is
"full code review and optimization", which names no symptom or measurement
and is filed unspecified rather than given a design.
Backlog item 64.
|
|
The thread list now updates itself when a sync finishes, whether it is
empty or populated. New threads appear where the sort puts them, threads
that stopped matching leave, and threads whose state changed repaint.
Refreshing used to mean re-running the query, which cleared the model,
the selection, the message pane and the undo stack, so 0.8.0 declined to
do it on a cron timer and asked the user to press Enter instead. The
result was a list that quietly disagreed with the database: mail indexed
by cron never appeared, and an Unread view read to the end sat empty in
front of it.
ThreadListModel::reconcile() diffs a result against the current rows by
thread id instead, so a surviving thread keeps its row, its persistent
index and its loaded replies. Order comes from the result and is never
imposed here, which is what makes the sort dropdown authoritative.
The undo constraint this was sized around did not exist: no undo entry
was ever keyed on a row. ThreadTagCommand stores thread ids and
MessageTagCommand stores message ids, and applyTagChange() looks its
target up by id, so an entry already survived its rows leaving the view.
A thread read out of the current view now leaves the list, which is
correct and would otherwise strand the reader, so MessageView grows a
notice saying the open thread no longer matches, with a button that
re-queries it. Recovery lists the whole conversation, expands it, and
restores the message that was on screen rather than reopening at the
first one.
Ten defects were found building this, nine of them by hand testing:
- SyncMonitor::start() polls synchronously, so an idle lock file emits
stateChanged(Idle) from inside buildUi() and the first handler to
touch a widget segfaults before the window exists.
- QTreeView sets a current index when it takes focus with none set, and
current drives loading, so new mail opened itself and was marked read
without the user having looked at it. Selection is now required.
- The notice outlived what it described, both when the pane was blanked
and when another message replaced it.
- Retiring the "Background sync completed" message left the bar claiming
a sync was still running: silent means saying nothing new, not leaving
a stale claim on screen.
- A thread root sets both the thread id and the message id, so treating
the message id as the message-row case discarded it for the commonest
way to open a thread.
- A freshly queried root does not know its own first message until the
tree loads, so recovery selected nothing and left the pane blank.
- A user query mid-recovery had its result hijacked by the pending
selection.
- MessageView emitted the recovery signal with its own members, so a
direct connection handed MainWindow references that runCurrentQuery()
then cleared by blanking the pane. The ids went empty mid-slot and no
recovery ever ran. Every test passed against this, because reaching a
slot through invokeMethod copies its arguments.
A Qt signal argument is a reference until something copies it. Emitting
a member to a slot that can re-enter the emitter is a use-after-write,
and it presents as a wrong value rather than as a crash.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
Two tests fail about one full run in twenty, and neither is caused by the card
list: a worktree at f72dba9, before any of that work, failed 3 of 12 runs, and
neither test was touched by it.
Recorded with what was measured and what was not. The cause is NOT established:
synthetic CPU load did not reproduce it, so the trigger is narrower than "the
machine is busy", and the entry says so rather than shipping a plausible guess
for the next reader to trust.
|
|
Three passages claimed the code was not on master, which stopped being true at
the merge. The distinction worth keeping is which branch a reference points at:
item 20's file and line references still mean `item-20-message-rows` at 029a50e,
kept unmerged as the record of the presentation the user rejected, while master
carries the card list built on top of it.
|
|
The architecture section described ThreadListView as existing to paint a strip
across columns. That was true until this change and is now the opposite of true:
it survives only for the expander hit-test. Kept as one paragraph of history,
since it explains the file's shape, but no longer stated as current behaviour.
Two traps are recorded inverted rather than deleted, because the rule survived
its own reason changing. The reply indent is still asserted on where the TEXT
lands, but where visualRect lies has flipped: it used to report an indent the
text did not have, and now reports none while the text is indented. And Q_ENUM
is documented as insufficient for a queued Q_ARG, which cost a silently dropped
sort argument.
Item 60's recorded cause was wrong and is corrected in place. It was read off
master, where the row arithmetic really is current.row() + 1; the branch had
already fixed it a commit earlier with threadRowOf(). The entry stays, with the
correction, because the reasoning was sound and the tests it demanded now exist.
Items 20, 51 and 53 are marked built on the branch rather than done. Nothing is
merged and the user has not seen it, which is the whole point of Task 10.
|
|
The item sat as 'open, unspecified' since 2026-08-04, recording only that the
user's mental model differed from what was built. Described now from three
screenshots plus four decisions: the left pane gains message rows, the root row
IS the thread's first message, replies indent by true reply depth, and action
scope follows the selected row kind.
No confirmation dialog, per this project's standing rule. The hazard the design
introduces is ambiguity rather than destruction, since deleted is a tag and
every mutation is invertible, so the scope is named in the status bar before and
after the action instead.
Sized L, the largest item in the backlog and the first to warrant a branch.
The plan records four API facts verified against the installed headers, each
contradicting the obvious approach: replies are unreachable from a query walk,
thread-derived messages must not be RAII-wrapped, QTreeView lacks
isRowSelected(int), and a tree numbers rows per parent.
|