aboutsummaryrefslogtreecommitdiffstats
AgeCommit message (Collapse)AuthorFilesLines
30 hoursdocs: close items 170, 176 and 177Danilo M.6-173/+535
30 hoursfeat: show the dashboard when a conversation is selectedDanilo M.6-76/+603
A thread row has no message to render, so the pane shows the conversation instead. A thread of one message still opens its message on one click, and the automatic mark-read is not armed for a row that displays nothing.
30 hoursfeat: read a thread's digest from the indexDanilo M.3-0/+370
Senders, unread messages and an activity histogram for the dashboard, as a plain value struct over a queued signal. Everything comes from the index, so no message file is opened; the unread list is capped and unreadTotal carries the real number.
31 hoursfix: undo only what the write actually changedDanilo M.5-33/+373
Closes item 176. applyTags reports the messages whose tags really moved, and a command stores that rather than what it asked for, so undoing a mark-read no longer marks the whole conversation unread.
31 hoursfeat: add the thread dashboardDanilo M.6-0/+765
A widget over a ThreadDigest: header, tags, counts, the unread list capped with a link to the rest, and an activity sparkline, scrolling under a pinned action strip. It invents no colours. ThreadDigest::unread is QVector<MessageNode> rather than QVector<MessageRef>. The dashboard draws rows for messages it never opens, which is what MessageNode exists for; MessageRef carries only what the pane needs once a message is already open and has no subject, sender or date. The struct's no-file-opening contract still holds, since all four of those fields are served from the notmuch index.
31 hoursfeat: judge a row's membership on the thread's unionDanilo M.5-0/+405
Closes item 170 under item 177. A conversation belongs to a view while any of its messages match, so reading one message of a thread no longer takes the conversation out of the Unread view. The current row is never evicted, and an automatic write defers its eviction until the selection moves.
32 hoursfeat: treat a mixed conversation's unread state as unreadDanilo M.2-56/+95
Item 112 hid the toggle whenever the selection disagreed, because a union is not a state and no honest label existed for it. That was affordable because the "Whole thread" submenu sat beside it carrying two absolute entries, which worked whatever the mix. Item 177 deletes that submenu: the row decides the scope, so a second set of actions is a second answer to a settled question. Hiding the toggle then leaves the commonest conversation in the mailbox with no key at all. The rule is a catch-all instead. Any unread message, a mixed conversation included, reads "Mark thread as read" and marks every message read; only a fully read selection reads "Mark thread as unread". Two presses therefore reach either state from anywhere, which is what makes one key enough. The write direction moves with the label. Computing it from everySelectedRowHasTag() while the label promised "read" would mark a mixed conversation unread, which is the item 112 report happening again from the other end; the mutation putting that back fails the new test. The three-valued selectionTagPresence() is unchanged and still asked, since Every and Mixed differ for other callers. Only this label collapses them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEK9z5D3oa1nVmJ6xpQhBs
36 hoursdocs: settle the unread toggle on a mixed conversationDanilo M.1-0/+16
One key: mixed reads Mark thread read and marks every message read, a fully read conversation reads Mark thread unread. Two presses reach either state, which is item 112's requirement met with a label rule rather than a second action.
37 hoursfeat: scope an action to the row it was invoked onDanilo M.10-928/+1062
The five *_thread actions and their submenu are gone: the row's identity is what decides the scope, so a second set of actions was a second answer to a settled question. mark_thread_unread went with them, being the sixth entry in the same submenu. tagSelected() loses its TagScope parameter, and everySelectedRowHasTag() its own, so the direction and the write ask the same question of the same object. ThreadListModel::scopeFor() and messageScopeFor() are deleted; scopeForSelection() is the one resolver. Labels name the scope. Archive, Delete, Restore, Spam, Important and the unread toggle all say "thread" on a conversation row, and Delete, Restore and Archive are ABSENT on a reply: a single reply cannot be removed from a conversation. Compose follows the same rule. Forward, Save, Reply-all and Reply without quoting disappear on a conversation row, which shows no message to act on, and Reply becomes "Reply to this thread": reply-all, quoting nothing, threaded off the conversation's NEWEST message so the answer lands at its end rather than forking the discussion at its opening post. That id is not in the model, since an unexpanded conversation holds no nodes for its replies, so it comes from resolveThreadMessages(); resolveQuery() states its newest-first sort rather than inheriting notmuch's default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012iDeN6C7y97nHYPvP6ST4L
37 hoursdocs: state why the thread reply is reply-allDanilo M.1-4/+6
Replying to the sender alone in a multi-person thread drops everyone else from a conversation they are part of, while reading as a reply to it.
37 hoursdocs: settle reply, forward and save on a conversation rowDanilo M.1-0/+24
Forward and Save need a message and disappear; Reply becomes one lean 'Reply to this thread', quoting nothing, reply-all, threaded off the newest message. composeReply already supports it, so no new compose machinery.
37 hoursfeat: resolve a selection's scope from what each row isDanilo M.3-0/+138
One resolver replacing the scopeFor/messageScopeFor pair. The caller no longer chooses the scope, which is what let one gesture mean two things.
37 hoursrefactor: remove the sibling chip tierDanilo M.5-236/+12
Nothing is a sibling any more: a conversation row draws the thread's tags and a message row draws its own.
37 hoursfeat: draw a conversation row's own tags, in one tierDanilo M.6-393/+40
Items 110 and 111 reconciled a card that showed one message with a row that was a thread. The row is the conversation now, so the union is simply what it means: the first-message substitution, PillOwnCountRole and the seeded first node all go.
38 hoursfeat: let a row say whether it is a conversationDanilo M.3-0/+110
One predicate for the question every scope, label and membership decision in item 177 keys on. It repeats hasChildren()'s rule deliberately: an expander and a conversation are the same fact, including that a loaded thread trusts its children over a count that included duplicates.
38 hoursdocs: plan the thread row identity changeDanilo M.1-0/+1390
Eleven tasks over the five stages the spec sets out, TDD throughout. Tasks 1 to 6 are one coherent change and the tree behaves oddly between 2 and 6, so the first useful hand test is at the end of 6.
38 hoursdocs: settle the dashboard's coloursDanilo M.1-3/+21
Every colour on the pane already means something elsewhere: a sender's hashed identity, a tag's, or the palette's own emphasis. Avatar::colourFor() hashes the address, so a participant's colour is already stable across threads and nothing new had to be decided.
38 hoursdocs: settle the dashboard's scrolling, action strip and unread capDanilo M.1-6/+21
38 hoursdocs: specify what a thread row stands forDanilo M.2-1/+374
Four separate questions in one session turned out to be one question: a row means a message for display and action, and a thread for existence and membership. The spec settles it as the conversation, and records item 177 plus item 176, the thread-scoped undo defect found while hand-testing.
40 hoursdocs: record the item 171 hand test and its final shapeDanilo M.2-3/+5
The row still described the multipart/alternative build that was reversed; the shipped forward sends one part chosen by the Send-as-HTML toggle, with the original in a read-only pane beside the editor.
2 daysfeat: forward an HTML message with its formattingDanilo M.19-75/+1749
Item 171. A forward carried only the plain-text version of the original, so formatting was lost; and an original with no plain-text part at all (30 of 342 sampled inbox messages, ~9%) forwarded as an empty quote with its content silently gone. A forward now sends ONE part chosen by the Send-as-HTML toggle: the original's markup when on, the text quote when off. Not a multipart/alternative, at the user's decision: a forward's shape is already decided by that toggle, and sending both hands the choice to the recipient's client. The toggle is honoured even for an HTML-only original, which then forwards as a text fallback. HtmlSanitiser strips remote content from the forwarded markup, checked by default with a per-forward opt-out. This is the security-critical part: the markup leaves this process and is rendered by the recipient's client, where none of MessageView's protections apply, so forwarding a tracking pixel forwards the tracking. It is an ALLOW-LIST, unlike HtmlBuilder::namespaceCids(), because a missed rewrite is a broken image while a missed strip is a beacon reaching the recipient. An HTML forward does not seed a text quote into the editor. The first build did, then subtracted it when building the HTML part, so the user could edit a quote whose edits were discarded; what the composer shows must be what gets sent. The forwarded message appears in a read-only pane beside the editor instead, a QSplitter at 60/40 with a toggle in the Format menu. A plain forward is unchanged. ComposeContextBuilder::quoteBody() renders htmlBody down to text when there is no plain part, so the plain path never emits an empty quote. Design in docs/superpowers/specs/2026-08-27-forward-html-design.md. Two tests repaired for the splitter: the 60/40 assertion reads stretch factors rather than pixels, since the offscreen platform gives the splitter no width and reports 49/49 whatever the code asks; and theComposerSplitsItsToolbarByScope looked for the body directly in the composer's column. Not yet hand-tested in this arrangement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
3 daysdocs: drop item 164, the inbox tag on a draft was never thereDanilo M.2-104/+145
Re-measured at message level: 0 of 12 drafts carry `inbox`, including nine written on or before 2026-08-25, when the item was filed. The thread that produced the original report splits into an arrived message tagged `inbox` and a draft reply tagged `draft unread`; neither carries both. The premise came from `notmuch search --output=tags`, which displays the union over a thread. The trap has a second half: a thread-level `notmuch count 'tag:draft and tag:inbox'` also returns 0, because search terms match per message even in a thread query, so the count and the displayed tag list disagree and the displayed list is the one that looks like evidence. The investigation is kept above the correction rather than deleted: it cost a week open, two wrong causes and a seven-variant reproducer built to explain an end state a union produces for free, and it caught a fresh reader again on 2026-08-27. The `unread` half of the original observation was real and is item 172, fixed in edbf393. The reported `draft inbox unread` is fully explained: `unread` from the missing S flag, `inbox` from the arrived message sharing the thread. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
3 daysfix: flag a saved draft seen so it is not tagged unreadDanilo M.6-47/+220
DraftStore::write() was called with "D", and it uses the flag string verbatim, so every draft this application wrote landed as :2,D. With maildir.synchronize_flags on, notmuch tags any message lacking the S flag `unread`, and a draft the user authored is seen by definition. The symptom heals itself: the next sync of that folder round-trips the file, adds S, and the tag goes away. Only the newest draft in a folder that has not synced since shows it, which is why it read as intermittent and why measuring an older draft finds nothing wrong. TestComposeWindow::aSavedDraftIsFlaggedSeen() asserts both flags on the written filename, verified failing first against "D". TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag() asserted endsWith(":2,D"), pinning the whole flag set where its own comment said the point was the draft flag "not left bare", so it failed against the corrected behaviour. It checks for D within the flag set now. Also reconciles the backlog with the user's notes: records the forwarded-HTML defect as item 171, closes item 169 (shipped last session, its row still read open and its section was still in the open file), and records this fix as item 172. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
3 daysdocs: record item 119 and close it in the backlogDanilo M.5-59/+165
The status rows for 119 and its duplicate 146, 119's section moved to the closed file on this commit rather than left for a later cleanup, and the README and changelog entries for the feature. CLAUDE.md gains three findings, all of which cost time to learn here: A defensive counter for an unreachable case is worse than nothing, because it blocks the feature that needs the data. Reading the code said that branch was reachable and the reading was wrong; instrumenting it and running the suite is what settled it, and the tests that appeared to exercise it were driving it from outside the production path. PendingChangesDialog groups by a run rather than a map, which is why the snapshot is stable-sorted, and startsMessage is carried rather than inferred so a stale row still opens its own run. A queued call carrying a container deserves the same suspicion as a Q_ENUM, with the measurement: both containers cross intact on Qt 6.11, but a standalone probe found QMetaType::fromName("QList<int>") invalid while QList<bool> resolved, so the property does not follow from the type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysfix: size the pending-changes dialog to its contentDanilo M.1-1/+11
380px of dialog for three rows left most of itself empty. The height is asked of the layout now, capped so a long list scrolls rather than growing past the screen and floored so a single row does not collapse it. A background role on the scroll viewport was tried in the same pass and reverted: the dialog renders semi-transparent under the developer's compositor, and painting a Base-coloured layer under the list made that worse rather than better. The transparency is the desktop's own doing, which is the trap this project has already recorded for window geometry. Not covered by a test. The offscreen platform returns an identical frame for a correct size and a broken one, so an assertion there would pass against both; CLAUDE.md records that measurement. Confirmed by hand instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysfeat: open the unsynced-changes count to see what it countsDanilo M.10-0/+561
Item 119, and item 146 which is the same request recorded again. The status bar's count answers "is my work safe to quit on" and could not say what the work was. The label opens a read-only list on a click. A QLabel has no clicked signal, so the press is taken by MainWindow's existing event filter rather than by replacing the label with a flat QToolButton, which would have brought the style's button metrics into a status bar the label already sits correctly in. The pointing-hand cursor is the affordance, since a status-bar label has room for nothing else. The layout is the user's own: a message appears once with its actions beneath it. PendingChangesDialog::rowsFor() does the grouping over a run of rows sharing an id, which the snapshot has already ordered, so the actions under one message keep the order they were made in. Read-only, deliberately. Retrying or discarding a change from here would be a new mutation path with its own undo question, and the count exists to be understood rather than edited. Three rules the tests pin, each of which is a way the list could disagree with the count it was opened from: - Grouping must not collapse: two actions on one message are two rows. - A thread row stays thread-scoped and reports how many messages it covered. - An id the index no longer holds still opens a run of its own, showing that its subject is unknown rather than folding its actions under the message above it. This is why the row carries startsMessage rather than inferring it from a non-empty subject. The queued call carrying QStringList, QList<bool> and QList<int> is covered by a test that drives it across a real thread, since a container whose metatype does not resolve is dropped at runtime and the slot runs with a default. Both survive on Qt 6.11; the test is what says so, and what would fail if that changed. Italian ships with it: five new strings, lupdate clean, lrelease 522 finished and 0 unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysfeat: resolve pending-change ids to subjectsDanilo M.3-0/+171
Item 119, second half of the data: the step that turns the snapshot's ids into something worth showing. resolvePendingSubjects() takes the rows' ids in order, each flagged as a thread id or a message id, and answers positionally: one subject per input, plus the thread's message total for a thread id and -1 for a message id. Positional rather than set-based, and that is load-bearing. The caller has already decided what its rows are and in what order, and one id can legitimately appear on several rows: a message with two outstanding actions is two rows carrying one id. A combined query returns a set, which loses both the order and the duplicate, so the walk is one lookup per row instead. The cost is bounded by what the user did by hand since the last sync, which is not a query-sized number. A missing id answers with an EMPTY subject rather than being dropped. The dialog still shows that row, because the count the user clicked has to equal the list they are shown, and dropping a row breaks that agreement in exactly the case where the user is most likely to notice. An index that cannot be opened answers the same way, one empty subject per row, so the list still shows the changes with only the subjects missing. The thread count is taken at snapshot time and says so: a held thread edit applies when the sync ends, and a reply arriving in between makes the real number larger. The row describes what the user is looking at, not what the write will touch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysfeat: snapshot the pending changes as rowsDanilo M.4-9/+215
Item 119, first half: the data the list behind the unsynced-changes count is built from, with no dialog and no worker, so the rules it has to follow are testable on their own. pendingChangeSnapshot() gathers the three queues the count sums into PendingChange rows. Two properties are the whole point. Scope follows the ACTION, not the storage. A held thread edit stays one thread row, because a `*_thread` action made it and reporting its messages instead would claim the user acted on each one; a netted tag edit and a held move are message rows. The queues already encode that distinction, so nothing is expanded and nothing is escalated. The rows are grouped by id, so a message with several outstanding actions appears once with its actions beneath it, which is the layout the user asked for. The sort is stable, so those actions keep the order they were made in; QHash has none of its own, and without it the list would reshuffle between openings. A snapshot, taken once and frozen. Subjects are empty here and filled by the resolve step to come. m_pendingTagEdits gains the action name beside the direction it already kept. The direction alone was enough to count with; a list has to say what each change was, and only the action that made it knows. It is carried from TagChange::description rather than derived from the tag, so there is no second table of tag names to labels to drift from the first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysrefactor: drop the unnettable pending-edit counterDanilo M.3-27/+49
Item 119's stated blocker, removed by finding out what it held: nothing. pendingEditCount() summed four sources, three of which can name the messages they hold and one of which was a bare int. That int counted confirmed changes carrying no message ids, on the reasoning that an edit which cannot be netted must still register rather than be lost. It was what made the count impossible to open and list, since a dialog would have shown three groups and then owed the user a remainder it could not describe. The remainder is empty. NotmuchWorker::applyTags() is the only emitter of tagsApplied(), and its first statement returns on an empty id list, which is the exact condition the counter required. applyTagsToThreads() resolves threads to message ids through a query and errors out when that comes back empty, so it can only ever hand applyTags() a non-empty list. Measured rather than read. A qFatal in the branch fired in 4 of 70 test_mainwindow cases, all four building a TagChange by hand and invoking the slot directly with no worker involved; an assertion before the worker's own emit never fired across the whole suite, worker-backed tests included. The worker's guard stays and is pinned where it lives, by applyTagsWithNoIdsDoesNothing() in test_notmuchworker. The MainWindow test that asserted the deleted branch is replaced by one for the consequence: a change reaching the indicator names its messages, and an edit with its inverse nets back to nothing, which is the property a growing-only counter could never have. Three tests that leaned on the counter to show the indicator now carry message ids, as a real edit always does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysfix: give a restored message its inbox tag backDanilo M.2-1/+27
Restore moved the file back to the inbox folder and left it invisible: the message carried no `inbox` tag, so the Inbox view could not see it, and the user reported restoring a message and losing it. Delete strips `inbox` so a deleted message leaves that view, which makes restoring it the other half of the same change. restoreResolvedMessages() already meant to add the tag back, and the comment above the branch describes exactly this failure, but the comparison deciding it read `origin`, which four lines earlier had been reassigned from the bare folder name to the finished tag. `deleted-from:Inbox` never equals `Inbox` however an account spells its inbox, so the branch was dead and the tag never came back. The destination folder is taken from the move's own key instead, which is what the surrounding code already builds and what the comment says is being compared. The existing test passed against this throughout. It asserted the file moved, the origin tag came off and `deleted` came off, all of which were true; nothing asserted the tag that decides whether the user can see the message afterwards. It does now, and fails against the old comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysfix: correct the avatar initials, the two-tone fill and the fadeDanilo M.10-58/+358
Hand-testing item 169 found four defects, three of them visible on every card. The initials were taken from whatever the card's first line held, which is the raw From header on a reply row and notmuch's comma-joined author summary on a thread row. A naive space split therefore gave `T<` for `tsujan <notifications@github.com>` and one letter each from two different people for `Standreas, tsujan`, and a separator counted as a word, so `INE - Expert IT Training` drew `I-`. Avatar::initialsFor() now normalises first: the angle-addr and any quoting go, a comma takes the first entry unless the name is quoted, a bare address is not a name, and a word has to carry a letter or a digit. Avatar::fillFor() uses the same normalisation, so an address in the name's place no longer reads as a person. The two-tone fill built its gradient axis as a radius from the centre, so the 0.5 colour stop landed on the squircle's edge and one hue filled almost the whole face. The axis spans the diameter now. The fade ran left to right, which put its hard stop at 60% of the card and read as a slab rather than a wash. It runs right to left: opaque at the card's right edge, where the only hard stop is the card's own boundary, and gone before it reaches the accent bar that already states the account. And the flat views hashed the user's own address on every row, so every Sent and Drafts card shared one pattern. ThreadSummary::firstMessageRecipient rides the recipient fold, which already parses the To header, and SenderAddressRole prefers it, falling back to the sender when there is no usable To. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P88Q3MCSCSQxKDy7pmXh9F
3 daysfix: harden candidate appends and cover the message-row sender rolesDanilo M.4-26/+93
3 daysdocs: document card avatars and the business-senders listDanilo M.2-0/+31
3 daysfeat: propose business senders from newly synced mailDanilo M.4-0/+125
3 daysfeat: load the business-senders list at startupDanilo M.3-1/+83
3 daysfeat: draw a sender's avatar on every cardDanilo M.3-0/+102
3 daysfeat: fade the account colour across a cardDanilo M.3-0/+71
3 daysfeat: expose a row's sender to the delegateDanilo M.5-0/+57
3 daysfeat: reserve a card's avatar gutterDanilo M.3-2/+91
3 daysfeat: propose business-sender candidates, always commented outDanilo M.3-0/+211
3 daysfeat: read the business-senders listDanilo M.5-0/+239
3 daysfeat: paint the avatar squircle from a hashed seedDanilo M.2-0/+108
3 daysfeat: choose an avatar fill and derive its colourDanilo M.2-0/+50
3 daysfeat: derive a sender's avatar initialsDanilo M.5-0/+252
3 daysfeat: carry the first message's sender address on a thread summaryDanilo M.3-0/+114
3 daysdocs: scan every message on the first candidate runDanilo M.2-3/+87
A week-long scope is right once the list is in use and wrong on the first run, when it proposes almost nothing and leaves the file taking months to become useful. BusinessSenders::scanQuery() returns "*" while the file holds no active entry and date:1week.. afterwards. A file holding only rejected candidates counts as unused, which costs one more full scan and re-proposes nothing, since appendCandidates already skips every address the file mentions in any form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
3 daysdocs: plan the card avatar and account fade workDanilo M.1-0/+1907
Fourteen tasks against the item 169 spec, TDD throughout. Two new namespaces of free functions over values, Avatar and BusinessSenders, so the initials, the fill choice and the list parsing are all assertable without a painter or a widget. The plan records where the existing traps apply rather than leaving them to be rediscovered: the two separate switches in data(), the inclusive QRect::right(), the queued-connection metatype registration, and the rule that a count request must not bump the query generation. Task 14 is the hand-off: this item is judged by looking, so the plan names the five things to look at and the constants most likely to move. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
3 daysdocs: reconcile the backlog, and specify card avatarsDanilo M.2-8/+424
Reconciliation against the user's notes found two unrecorded lines, and corrected the cause of one entry that was recorded wrongly. - 169, new: a card shows the account only as a bar, with no fade and no avatar. Half of it shipped as the accent bar. - 170, new: a row that stops matching the view only leaves it on the Delete path. Filed from a note that reads as a stale request for optimistic updates; it is not. removeThreadsWithoutTag() has exactly one caller, so marking a message read in the Unread view repaints the row and leaves it in a list it no longer belongs to. - 65, narrowed: the notes now name it as a dead-code and duplication sweep rather than a performance or security pass, so it produces a list to decide on rather than a diff. The spec for 169 covers the avatar geometry, the fade, the two hash-generated fills and the sender list that chooses between them. It also records the one structural change the feature needs and the measurement that forced it: ThreadSummary::authors carries display names only, with no address anywhere, so the identicon has nothing stable to hash and the sender list has nothing to match. ThreadSummary gains firstMessageSender, filled by the walk that already fills firstMessageId. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
3 daysfeat: flag what you answered, mark what was forwarded to youDanilo M.25-79/+1190
Item 68, which turned out to be three things once its premise was measured. The note asked to extend a "passed" subject rule to "Fw:"; there was no subject rule, and the correlation it rested on did not exist. What did exist was a gap nobody had reported. Reply and forward now flag their source. The Maildir R and P flags, which every other client sets and notmuch reads back as "replied" and "passed", had never been written here: measured on the developer's index, all 317 "replied" and all 6 "passed" came from other clients. ComposeWindow emits sourceMessageAnswered after a successful send and MainWindow routes it through sendMessageTagChange, message-scoped and off the undo stack, for the reason auto mark-read is: the flag records that the mail went, and the send cannot be undone. ComposeContext carries sourceMessageId rather than reusing inReplyTo, which is deliberately empty on a forward so the recipient's client does not file it under the thread it left. Keying on it made the "passed" half dead code that compiled and never fired. A resumed draft is excluded: its kind records how the file was opened, not what the user is doing, so flagging on it would set R from a guess. A received forward gets its own mark. Derived from the subject at paint time, storing nothing and reaching no server, because "passed" means "I forwarded this" and setting it from a guess would assert something false on 222 existing messages. subjectIsForwarded() shares forwardSubject()'s prefix table so the two cannot disagree, strips a Re: chain first, and takes extra locale spellings from [general] forward_prefixes, which extends the built-in table rather than replacing it. A mutation survived the first round and corrected a claim in the code: QRegularExpression::escape already makes a punctuation prefix inert, so the word guard is not about pattern validity. It stops a configured "-" matching "-: x". The comment and test say that now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXCZFLXbAii5n5wtovpdhh
4 daysdocs(changelog): record this session's user-visible changesDanilo M.1-0/+40
Six entries under Unreleased, covering items 104, 112, 118, 166, 167 and 168. The two fixes are worth a user reading them: one made the application look like it had stopped syncing, and the other lost mail from the account that received it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HFuRPtzFrSxCQjFk6tq7gD