| Age | Commit message (Collapse) | Author | Files | Lines |
|
Item 104 is fixed and its entry was wrong. It named mbsync's folder
patterns as the leading theory and concluded the cause was most likely
outside this repository; the reproduction put it at layer 3, in the
worker's own handle. The superseded theory is kept, since it would
produce a similar symptom and remains worth checking first in any future
report of this shape.
Two measurement errors from the diagnosis are recorded with it. A bare
`inbox` in a notmuch query is a free-text term rather than a tag term,
and the application generates `tag:inbox`; reading a message's tags
across every file matching a subject mixes several accounts' copies into
one answer. Each produced a confident wrong answer before it was caught.
Item 166 is new, found while setting up msmtp. The `post-new` hook's
sent-folder carve-out judges provenance by a file's path, but notmuch
deduplicates by Message-ID, so mail sent between two of the user's own
accounts is one message with a file in each. The carve-out matches the
sent copy and strips `inbox` from the message the recipient's inbox copy
also belongs to. Three options are laid out; the fix is a two-repo
change and the hook runs unattended on live mail, so it needs a decision
rather than a patch.
|
|
Found while hand-testing items 163 and 164: four saves produced four
distinct ids, and one reopen-and-edit turned one into another.
Cause verified in the code rather than inferred. MessageBuilder::build()
calls g_mime_utils_generate_message_id() unconditionally and every
autosave calls build(); OutgoingMessage has no field to carry an id in,
and ComposeContext has none for the draft's own id either, since
inReplyTo and references are the ORIGINAL's when replying. So a stable
id needs a field threaded from forDraft() through both structs, not a
changed call site.
Filed as needing a DECISION rather than an implementation, because what
a draft's identity is is not obvious: a stable id reused at send makes
the draft and the sent message one message but means the server saw that
id before anything was sent; a stable id discarded at send keeps
revisions collapsed while drafting and threads under a fresh one; the
status quo never reuses an id for two different things, which is its one
real virtue.
Not urgent and explicitly not blocking item 163, whose fix restores
correct file replacement. This is the property that turned that fork
into two MESSAGES rather than one duplicated file, and the remaining
route to it is an interrupted save, since DraftStore::write() unlinks
the previous revision only after the new one is safely on disk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Its section moves to the closed file on this commit, with the outcome
recorded: MaildirName::resolveRenamed() wired into all three read sites,
the two deliberate refusals (ambiguous match, genuinely missing file)
and why each has a test, and the note that forDraft() must seed
draftPath from the resolved path or the fork simply arrives one step
later.
The stable-Message-ID question is recorded as left undecided rather than
quietly dropped: it is what turns a stale path into two server-side
messages rather than one replaced file, and a draft's id is not yet the
sent message's id, so it wants its own item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Item 162 is done and its section moves to the closed file on this
commit, with the outcome recorded on it: moveMessages() re-resolves by
message id when the recorded path is gone, and the hold candidate was
investigated and rejected because aSyncHoldsTheWriteLock() guards
notmuch's lock while the window sits between mbsync's rename and that
sync's notmuch new.
Item 163 gains a second site, found while hand-testing 164 and worse
than the one it was filed for. openComposerFor() passes ref.filePath to
forDraft(); after mbsync renames the file the parse fails and the reopen
is refused BEFORE any composer exists, so composing again starts fresh
with no previous path to unlink. The old revision survives, each save
mints a new Message-ID, and both revisions reach the server. The unlink
machinery is entirely correct and never runs. Its heading and row now
name both sites, and the note that 162's fix would cover it is removed:
that fix re-resolves inside the worker, while these hold a stale path in
the UI.
Item 164 gains the reproducer's findings. Seven variants in throwaway
databases establish that index_file applies no tags, that the carve-out
strips inbox correctly in every filename shape and ordering tried, and
that the only reproduction is a pass applying inbox while tag:new is
already spent. The trigger is still not established, and the entry says
so: the live log shows the hook ran on the affected pass and logged
success, which the new match-count instrumentation will disambiguate on
the next occurrence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
The entry blamed a missing drafts helper. Neither half of that was true.
NOT_ARRIVALS in qtmaildirconf.py is ("sent", "drafts"), so sent_folders()
already returns both; the function name says sent and its contents do not,
which is what made the wrong reading plausible. Run against the real
config it returns every account's drafts folder, and notmuch count over
the carve-out query and the affected message id returns 1: the query the
hook builds MATCHES the draft. The folder list and the query are correct
and the fix is not there.
A second theory is also recorded as dead. An mbsync-style rename does not
re-apply new.tags: measured in a throwaway database, a file renamed to add
,U=4 and reindexed kept the tags it had.
What is established: the carve-out is scoped to tag:new, the draft carries
inbox, and tag:new is 0, so it was never in scope when the hook ran. The
installed hooks are symlinks into this repository, verified rather than
assumed, so the code read is the code that runs.
What is not established is which pass tagged the file. The likely shape is
an ordering one, since item 158 indexes a draft from the application
itself and a file already known to the database is not new on the next
pass, but that is a third hypothesis and the first two were both wrong.
The entry now calls for a reproducer driving the real sequence before any
code is written, since the hook tags real mail unattended every ten
minutes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
163: the message pane names a path that no longer exists and reports the
message unreadable. Same mechanism as 162, different site and different
fix. mbsync renames the file to add ,U=<uid>; by the time the pane fails,
notmuch is already CORRECT and the stale path is the MODEL's, cached when
the row was loaded. Measured: the index named the ,U=4 file while the
pane named the pre-U= one. 162's likely fix, refusing to write while a
sync runs, does not touch the read path. Points at recovering by
re-resolving the id, the way recoverStaleThread() already does, with a
bounded retry so a genuinely unparseable message still reports.
164: every newly synced draft carries inbox. Measured "draft inbox
unread" on a draft this application wrote. strip_inbox_from_sent() reads
qtmaildirconf.sent_folders() only, and qtmaildirconf.py has no drafts
equivalent, so the carve-out never covers a drafts folder. Item 158's
measurement was right and did not reach this: index_file assigns no tags,
but mbsync's upload and the next notmuch new re-tag the file.
164 also contradicts the shipped 0.27.0 changelog, which claims sent mail
and drafts both stay out of the inbox. The drafts half has never been
true, so correcting the entry is part of that item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Found by hand while deleting a draft: "Cannot move <file> to <folder>".
Neither Delete nor item 158 is at fault. mbsync uploads a saved draft and
RENAMES it to add its ,U=<uid> infix, and notmuch keeps the pre-U= name
until that sync's notmuch new runs, so moveMessages() calls QFile::rename
on a path that no longer exists.
Verified against the live Maildir rather than read: notmuch named a file
that was not on disk while a sync was running, and the same query was
clean afterwards with the file present under its new name. That is why it
reads as intermittent and why it heals itself.
Truthful and lossless, but the action silently does nothing and the
message blames a folder for a timing problem, which sent the user looking
at a configuration that was correct.
Records both candidate approaches and notes the likelier one: refuse the
move while a sync holds the lock, joining the held-edit machinery items
97 and 106 already built for exactly this shape, rather than re-resolving
the filename and racing the same window. Also notes that this is the ,U=
trap CLAUDE.md records for MaildirName::fresh(), seen from the other
side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
File, Edit and Format, to the scope the user chose. Save draft (Ctrl+S)
is the only new action: saveDraftNow() was reachable from the autosave
timer, the send path and closeEvent, so there was no way for the user to
ask for a save. It routes through that same function, which is what
emits draftSaved for item 158's indexing, reports through item 160's
status bar and raises the failure banner; a second write path would have
to repeat all three.
The menus show the toolbar's own QAction objects rather than copies, as
item 140 required for the message pane's bar. Two needed hand-building.
The HTML toggle is a QToolButton and cannot go in a menu, so a checkable
twin mirrors it in both directions, since a menu entry that only follows
the button is half a control. The signature entry takes the switch's own
QMenu pointer, because that menu is rebuilt whenever the signatures
change and copied entries would go stale.
Edit's entries drive QPlainTextEdit and follow its own undoAvailable and
copyAvailable, so a greyed entry tells the truth about what pressing it
would do.
theMenuBarReachesEveryComposerAction() is item 132's reachability rule
applied to the composer: it walks the real menu bar and collects the
composer's actions with findChildren, so an action added to the toolbar
and forgotten in the menus fails without the test being touched. It
skips actions owning a submenu, since Qt emits no triggered for those.
The composer's actions stay out of KeyMap, per item 148: they are
parented to this window, so they are WindowShortcuts dispatched to the
active composer and the main window's namespace is untouched.
lrelease reports 496 finished, 0 unfinished.
Closes item 161.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Autosave worked and said nothing on success. The only feedback was
m_banner, which is the failure channel and whose persistence is
load-bearing for the quit path, so success got its own channel rather
than sharing one.
The fix is a funnel, not a label. m_dirty had seven writers, four of
which clear it and only two of those are a save: the constructor clears
it because seeding is not an edit, and the send handler clears it
because the message is gone. A cue hung off saveDraftNow() would have
been silently wrong in both. setDirty() is the only writer now, and it
refreshes the status cue and setWindowModified() together so neither
display can drift from the flag.
The age line needs a tick of its own, since it moves with no edit to
drive it. Five seconds against a label that reads in tens of them.
Two defects found by probing rather than by reading. The %n plural
rendered as "2 minute(s) ago" for every English user, because Qt picks a
plural form only when a translation supplies the forms and there is no
English .ts; it uses %1 and "min" now, which Italian substitutes
identically. And the status mark was inside the translatable string,
where a translator could drop it; it is concatenated outside tr().
Presentation reworked after the user looked at it. The first version
reused item 151's yellow ribbon treatment, which reads as a misplaced
widget on a bare status label rather than as a warning, and put both
labels in the permanent widget area, which is the right-hand tray. They
are ordinary status text on the left now.
onlyTheSetterWritesTheDirtyFlag() asserts the funnel structurally, by
reading composewindow.cpp: the first test for the send path called
markClean() directly and a mutation restoring a direct assignment left
the whole suite green. Four mutations now fail. The suite still cannot
see the presentation, which is why that half needed a hand test.
lrelease reports 487 finished, 0 unfinished.
Closes item 160, and unblocks 161.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
Both are composer feedback, and both causes are verified in the code
rather than copied from the note.
160: autosave works and is silent on success. The only feedback is the
failure banner, whose comment explicitly rejected the fading status line
that success actually wants. m_dirty and the draftSaved signal already
carry both states; nothing displays them.
161: the composer has no menu bar, and Save draft does not exist as an
action at all. saveDraftNow() is reachable only from the timer, Send and
closeEvent, so there is no way for the user to ask for a save. Notes that
the composer's actions stay out of KeyMap per item 148, that
everyActionIsReachableFromAMenu() walks the main window only, and that
"duplicate the other actions" needs the user to say which, since most
message actions are meaningless over a message being written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
The Drafts filter shipped threaded in item 138, reasoning that a draft
reply belongs with the conversation it answers. That reasoning cost the
feature: a thread row stands for its first matched message, which for a
draft reply is the message being replied to, so the draft itself had no
row of its own and double-clicking the conversation opened nothing.
Reversed with the user. Drafts now follows Sent; Trash deliberately does
not, since a deleted message still belongs to its conversation and
nothing there has to be reachable for editing.
The view mode was decided in three places that each compared against
"sent" and had to agree: builtinFilter(), the reader that reapplies the
mode, and the writer that skips storing what the generator implies.
generatorIsFlat() is now the one closed set they share, and
builtinFilter() sets flat from it rather than inside a branch so the set
cannot drift from the labels.
Setting only the branch would have looked correct. Its save/load pair
survives by accident, because the writer's skip knew only "sent" and so
would have stored the key for drafts. The gap is the reader's fallback,
for a file carrying no flat key at all: an older build, a migration or a
hand edit comes back threaded against a flat button, and the next save
persists the disagreement.
theDraftsFilterIsThreadedNotFlat is inverted rather than deleted, keeping
its history, and now also pins Trash as threaded. The round trip is
covered by extending aGeneratedEntryWritesNoRedundantKeys, which already
asserted that property for Sent. Mutation-checked: reverting
generatorIsFlat() to "sent" alone fails both.
Suite 37 of 38; undoMovesTheMessageBack is item 136, pre-existing and on
an unrelated path.
Closes item 159.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUQS6n3cmsFrsjCNmwNtf8
|
|
|
|
|
|
indexes it
Found by hand: autosave writes the draft to the Maildir drafts folder but
never indexes it, and the Drafts view is a notmuch path: query, so the
draft cannot be reopened until notmuch new runs. Approach reuses the
single-file index moveMessages already performs.
|
|
Found incidentally while building item 152, by an agent that checked rather
than assumed: it ran test_mainwindow at the preceding commit in a throwaway
worktree and got the identical failure, so the signatures work is ruled out.
Records the assertion text, which is worth more than the flakiness history.
After the undo the message file is in neither cur nor new of the account
inbox, so the question narrows from "why does this race" to "where did the
file go", and the trash folder and the account root are the places to look
first. A move landing in the wrong folder is the mail-safety half of the
fork this entry already described, and it would present exactly this way.
The 70-second duration already recorded fits a QTRY_* waiting for a file
that is never going to appear.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
The plan's test fixtures carried the maintainer's own first name as the
signature text, which reaches a committed test file. Task 1 caught and
corrected it in the code; this corrects the source so tasks 2, 3, 5 and 6
do not reintroduce it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Eight tasks, TDD throughout, against
specs/2026-08-24-signatures-design.md.
Tasks 1 to 3 build the Signatures namespace: reading the directory, the
splice for both placements, and the match guard that keeps a "-- "
delimiter from authorising a deletion. Task 3 carries a mutation check on
that guard, since it is the one piece preventing data loss.
Task 4 adds the three config keys, task 5 the editor-bar switch and the
seeding, task 6 the From: follow that stops once the user chooses. Task 7
is documentation and the Italian translation; task 8 closes the backlog
item, and deliberately hands the work over for a hand test first rather
than marking it done on a green suite.
MessageBuilder is untouched by every task, which the plan states twice: the
signature lives in the composer's buffer and both MIME parts are already
derived from it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Finding a "-- " delimiter is not enough to authorise removing what
follows it. The block is replaced only when its text matches one of the
signatures on disk; otherwise the new signature is inserted and nothing
is removed.
"-- " can reach the buffer without the user ever choosing a signature,
most plausibly pasted in with quoted text from another client, and the
unguarded scan would have deleted everything after it silently. The
failure is now directional: a block that matches is replaced, and one
that does not produces a second signature, visible in the editor and one
undo away. A wrong guess adds text rather than losing it.
Two markers were considered for the same problem and refused, both
recorded with the reasons. A zero-width character ships in the sent
message, fingerprinting the client in outgoing mail, and has to survive
the draft round trip through GMime, quoted-printable and MimeParser,
which is the pipeline that normalises such characters away. A doubled
delimiter is not the RFC 3676 separator, so no receiving client would
recognise the signature, and it would not have caught the pasted-text
case that prompted it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
One markdown file per signature under ~/.config/qtmaildir/signatures/,
spliced into the composer's buffer and switched from a control on the
editor bar.
MessageBuilder needs no change: it already builds text/plain from
markdownBody verbatim and text/html from MarkdownRenderer over the same
string, so one markdown signature in the buffer yields both forms
correctly. That is the "transparent to the user" requirement the note
asked for, and it is why a two-file text/HTML variant was dropped after
being chosen: it buys designed HTML signatures at the cost of the
signature no longer being visible while composing.
The switch stays stateless. seedBody() deliberately refuses to track
"my text" and "the quote" as separate pieces, and a toggle cannot duck
that question the way the quote did; it answers it by scanning for the
last "-- " block not followed by quoted lines, so nothing can desync
from the undo stack. That same scan is what lets signature_position
offer both end (the default) and above_quote over one implementation.
The per-account key does not reopen the note's constraint: an account
seeds the choice, the switch keeps every signature reachable, and the
automatic follow stops once the user picks one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Item 94. The query row is the six built-in filters (Unread, Inbox,
Important, Sent, Drafts, Trash), which compose with the account
dropdown, and every saved query lives in the More queries menu. Nothing
has to decide which of the user's queries get button space, which is the
question item 93 would otherwise have had to answer.
SavedQuery::pinned is gone from the struct, the reader, the writer, the
save dialog's checkbox and the pin/unpin context action.
The stored key is stripped rather than left ignored, at the user's
choice. That has one non-obvious requirement: `pinned` stays named in
loadSavedQueries' `known` list precisely so it is NOT collected as an
unknown field, since those are preserved and written straight back. A
mutation removing that name puts the key in the file for ever.
Confirmed with the user before starting that the built-in set covers
their use, since removing pinning removes the escape hatch this item was
blocked on.
Tests: four pinning tests replaced by two on the new rule, four more
converted from buttons to menu entries. migrationPinsEveryEntry and
aStoredGeneratedQueryIsUnpinnedNotDropped are rewritten around the
property that outlived the flag rather than deleted: an entry must be
KEPT, which is what both assertions were really guarding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Item 157, the half item 153 did not close. A draft was editable by
double-click and by a Message-menu entry, neither of which is where the
user looks while reading one. populateMessageBar() swaps the reply pair
for edit_draft on a displayed draft.
Three things came out of hand-testing it, each invisible to the tests
written before them.
The bar keyed on currentIndex(), which a query leaves valid on a row of
the discarded result, so it kept the draft button after switching to the
inbox and the reply pair after switching to drafts. This is item 150's
trap one level up. It answers from m_currentMessageId/m_currentThreadId
now, which every blanking route clears, refilled from
showPlaceholderPane(), the one site all five of those routes share.
That exposed a defect predating the bar: updateComposeActions() ran only
from the two selection handlers, so Reply and Forward stayed enabled over
a blank pane. Invisible while they sat on the main toolbar among
always-on actions.
The bar is hidden over an empty pane, so it comes and goes with the
subject and the details button rather than hovering over the logo. That
in turn broke the showing half: setBarActions() runs before showThread()
fills m_items, so the first message opened after a blanking left the bar
hidden and the second showed it from stale items, one selection behind
for the life of the view. updateHeader() shows it, beside the details
button it rides with.
The test missed the last one by asserting before the render landed,
measuring the placeholder; it waits on showingPlaceholder() now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEcn3u19xPqv6ggD15PG4c
|
|
Item 153. DraftStore had a write() and no reader, and nothing opened a
composer from an existing message, so a draft rendered like ordinary mail
and could never be finished or sent.
ComposeContextBuilder::forDraft() reads one back. A new Kind::Draft seeds
every field verbatim: the subject takes no Re:/Fwd: prefix, and the body
goes in exactly as it was left, with none of seedBody()'s quote framing. It
is reachable by double-click and by an edit_draft action in the Message
menu.
Three things the shape of this depends on.
A resumed draft must OWN its file. Maildir has no in-place edit, so an
autosave writes a new file and unlinks the old one; a composer that did not
know its own path would leave the original behind and one message would
become two. ComposeContext::draftPath carries it into m_draftPath, which the
autosave already knew how to replace.
MimeParser had no bcc, and nothing had ever needed one. MessageBuilder
writes Bcc into the draft file deliberately and explains why, so a resumed
draft that ignored it would drop every blind recipient from the message the
user then finishes and sends, reporting nothing.
edit_draft is gated on the file being inside a configured drafts folder,
matched on the PATH. A `draft` tag is not enough: notmuch surfaces the
Maildir D flag as one, and a message flagged by another client sits in the
inbox. Offered on ordinary mail, the composer would own a file it did not
write and the first autosave would delete a received message.
And a live defect found on the way, which is most of why this took as long
as it did. updateComposeActions() ran only from onSelectionChanged. Both
signals fire for an ordinary click, so nothing had noticed; but running a
query and setting the current index emits currentRowChanged ALONE, so the
enablement was computed against the previously selected row. Edit draft
stayed disabled on a draft selected that way, and the reply family had the
same blind spot with no test that could see it. Now connected to both.
Reading currentRowChanged is safe here for the reason CLAUDE.md gives: it
answers "which row is current", and no count is read.
WorkerBackedWindow::AccountSpec gains a drafts field, which the two new
tests need and which no fixture could express before.
|
|
Item 138 gave drafts a button and immediately surfaced that they cannot be
edited: DraftStore has a write() and no reader, and no action opens a
composer from an existing message, so a draft renders as ordinary mail and
can never be finished. Filed as 153, a defect rather than an enhancement.
The user's note splits it in two, "double-clicking a draft should open the
editor" and "there's no edit action anywhere". They are one item: the second
is the general case and the first is one route into it.
154 to 156 are the rest of that note's list, all v2 and all unspecified to
different degrees. 154 and 156 are deliberately separate: a read receipt is
the reader's client to honour, a delivery receipt the sending server's, and
whether the latter can be requested at all depends on the send_command.
152 gains the constraint the user added today: a signature is not tied to an
account and is switched from the editor bar, which rules out a plain
[account.*] key as the whole answer.
|
|
Items 138 and 148.
The query row carried Unread, Inbox, Important, Sent and Trash, and no
Drafts, though the composer has been autosaving into each account's drafts
folder since compose shipped. Reaching them meant typing a query by hand.
Smaller than its size suggested: Account::draftsQuery() and
Config::allDraftsQuery() already existed for the placeholder pane's drafts
count, and builtinFilters() derives the row from kQueryGenerators, so the
work was the generator entry, two resolvedQuery branches, a label and an
icon.
It follows TRASH rather than Sent. Folder-matched like both, because `draft`
is a Maildir flag notmuch surfaces as a tag while the folder is what the
user means and what the composer actually writes into. But NOT flat: Sent is
flat so a thread cannot fold the user's own message back into the
conversation it answers, and a draft reply belongs with its conversation for
the same reason a trashed message does.
An account with no drafts folder shows no button, per item 103's rule. The
existing row test surfaced that by failing until its fixture configured one,
which is the rule working rather than a defect.
Ctrl+W closes the composer, which bound nothing at all: the only way out was
the title bar. The action is parented to the composer, so it is a
WindowShortcut dispatched to the active one only and the main window's
namespace is untouched, exactly like the formatting shortcuts. It calls
close() rather than doing anything of its own, since closeEvent() already
decides whether the draft is saved and a second route out that skipped it
would lose the message.
The Italian gains "Bozze"; lrelease reports 478 finished, 0 unfinished.
|
|
Items 142, 143, 144 and 145, to the layout the user described.
The composer had one addToolBar carrying three scopes at once: text
formatting, message composition, and the terminal action. It read as a menu
bar that is not one. There is now no window toolbar at all.
From: [.............] +--------+
To: [.........] [v Cc/Bcc] | Send |
Subject: [...........................] +
[B][I][</>][S][link]["] [Attach] [Send as HTML]
+---------------------------------------------+
| message text |
+---------------------------------------------+
[Remove] * report.pdf <- only when attached
Send is a large icon-above-text button beside the headers: it is the
terminal action and carries the weight to match. Formatting is a toolbar
widget in the central column directly above the text it formats, icon-only
with the words kept as tooltips, which is where a tooltip stops being
decoration. Attach and the HTML toggle ride the right end of that bar, past
a stretch, because neither formats text. Remove attachment sits with the
list it acts on and appears only once something is attached.
"Also send a formatted copy" becomes "Send as HTML": the old label described
a mechanism without naming it, leaving the reader to infer that "formatted"
meant HTML and that "copy" meant a MIME part rather than a second message.
Cc and Bcc hide behind a disclosure beside To:. revealCcBccIfUsed() only
ever shows, never hides, so nothing but the user's own click can make a
field holding an address invisible: a hidden recipient is a message going
somewhere the sender cannot see, which is worse than the clutter this
removes. The label is hidden with each field, since a QFormLayout holds the
two as separate items and hiding the line edit alone strands a "Cc:" over
empty space.
Two send-lock faults, one predicted and one not. The backlog warned that
setInputsEnabled() disabled the single toolbar wholesale, so the send-path
test was strengthened to name every control BEFORE the split; it then caught
Attach live during a countdown, where a file appended after MessageBuilder
has run is either dropped or added to bytes already sent, silently either
way. With every control named it failed again on format_bold: disabling a
QToolBar greys its buttons but leaves each QAction enabled, so Ctrl+B during
a send would have edited a message already being built, through a button
that looked unavailable. setInputsEnabled() now walks the bar's actions too.
The Italian translation is refreshed; lrelease reports 477 finished, 0
unfinished.
|
|
Three corrections from looking at the built bar.
Compose returns to the main toolbar. The split this was built to, "about a
message" against "about the list", does not survive contact: what matters is
what the action NEEDS. Reply and Forward are meaningless without a message on
display, while Compose needs none and is disabled only when no account can
send. So the pane's bar holds exactly the two actions that depend on what it
is showing, and Compose sits with the window-wide ones.
The bar moves below the subject and details rows, directly above the web
view. At the top of the pane it read as window chrome rather than as
belonging to the message. The transient notice bars stay above it: they
explain the message rather than offer an action on it.
Its icons were the style's own default, 16px, which is tiny beside a 32px
toolbar. They are now 7/8 of toolbar_icon_size, which is the 28 the user
asked for at their 32, derived rather than hardcoded so the relation holds
if that key changes. The test asserts the relation as well as the value,
since a bare 28 would stop meaning anything the moment the key moved.
m_headerLabel gains an object name so the placement test can find the row it
must sit below.
|
|
Items 139, 140 and 141, built together because the seam between them is
wasted work: 140 needs a container and 141 is that container.
The main toolbar had grown to mix two scopes. Sync, Archive, Delete, Mark
all read and Undo act on the list or the selection; Compose, Reply and
Forward are about one message. With everything in one row the distinction
was invisible, and Forward was on no toolbar at all, reachable only from the
Message menu, which is item 139.
Compose, Reply and Forward now sit on a bar above the message pane, and
LEAVE the main toolbar rather than gaining a second home: that is what makes
the toolbar's remaining contents mean one thing. Toggle HTML joins them at
the right end, separated by an expanding spacer, since changing how a
message is displayed is a different scope from acting on it. That layout was
the open design question item 141 recorded, and it was settled with the user
rather than guessed.
The actions are MainWindow's own QAction objects shown a second time, never
copies: a duplicate would carry its own enablement and drift from the menu
entry updateComposeActions() keeps in step. MessageView::setBarActions() is
the seam, so the pane still knows nothing about the window's action map.
Two things worth recording:
QToolBar has no addStretch(), so the separation is an expanding spacer
widget. A test asserting only on action ORDER passes with that spacer
deleted, measured, so it asserts on the spacer's size policy instead.
noTwoActionsShareAnIcon looked up the toolbar with an unnamed
findChild<QToolBar*>(). There are two toolbars now, so it is pinned to
main_toolbar: pointed at the pane's bar it would have asserted that a
thread action is absent from a bar that never holds any, and passed while
the rule it exists for went unchecked.
|
|
The previous commit fixed the cursor within each quote_position branch and
the user still saw the old layout, because the branches were already right:
what was wrong was the DEFAULT. quote_position shipped as `above`, and the
layout asked for is exactly what `below` already produced, a blank line at
the top with the quote underneath.
So the default flips, along with the fallback for a malformed value and the
warning naming it. Nothing needs an Upgrading note: compose has not been
released, so no config in the wild sets this.
Focus goes to the body whenever To: is already filled, which a Reply and a
Forward always are. The form's first widget took it otherwise, so the user
had to click into the editor before typing. A New message keeps the default,
since an empty To: is genuinely the first thing to fill in.
The focus test asserts on the window's focusWidget() rather than on
QWidget::hasFocus(): an unshown window is never active, so hasFocus() reads
false whatever the code does and would fail against a correct fix. Both
directions are mutation-checked, since focusing unconditionally passes the
reply case while breaking the new-message one.
|
|
The three out-of-band bars in the message pane were plain labels on the
pane's own background, so they read as part of the page they were trying to
interrupt. They now carry a severity, as the user asked for:
- yellow for a warning that explains a limitation and offers nothing to do
about it (the receive-only ribbon)
- blue for one offering an action (remote content blocked, stale thread),
with the button moved right of a stretch, where the eye ends up after
reading the sentence
Each severity carries its own light and dark set rather than one tint
dimmed, and the theme is read off QPalette::Base, the same surface
HtmlBuilder reads, so a bar and the message under it cannot disagree about
which way round the theme is. Every ground states its own text colour: the
palette's may be near-white, which is unreadable on a pale tint.
The blocked row had to become a widget on the way. It was a bare
QHBoxLayout, which has nothing to paint a ground on, and once wrapped, the
six sites that hid its label and button individually had to hide the
wrapper instead or an empty painted strip would have been left behind.
The test asserts on the stylesheet string rather than on pixels, per
CLAUDE.md on rendering probes: an unshown widget offscreen renders nothing,
so a pixel test would pass whatever the code does. It compares the COLOURS
the sheets name rather than the sheets themselves, since each names its own
widget and would differ by that alone.
|
|
MessageView::clear() resets the blocked-content bar, the stale notice and
the attachment bar by hand, and forgot the receive-only ribbon. Only
setReceiveOnlyAccount() ever hid it, and that is reached from
updateComposeActions(), which runs on a SELECTION change.
So the ribbon survived every route to a blank pane that is not one:
clear_pane, clear_selection, a new query, and a multi-row selection. It sat
over a blank pane, or over another account's mail, naming an account that
was no longer on screen and contradicting the live Reply button beside it.
Worth recording because it cost a test: the first one written for this
selected receive-only mail and then mail from a sending account, which is
how the report reads. It passed against the unfixed code, because that
gesture is precisely the one path that was already covered. The test now
drives clear_pane and a new query as well.
Its own trap, in the test rather than the code: clear_pane leaves the row
CURRENT, so re-selecting it emits no change and the ribbon is never
re-raised. The test moves away and back instead.
|
|
quote_position names where the QUOTE goes, so the reply belongs on the
other side of it and the cursor has to follow the reply rather than the
buffer. seedBody() moved the cursor to Start under both positions, which
is correct only for Below: under Above, the shipped default, it landed on
the "On ... wrote:" attribution line, so every reply had to have room made
before it could be typed.
End under Above, Start under Below.
The existing theQuotePositionDecidesWhereTheQuoteLands passed throughout
the defect and still does, because the quote was in the right place all
along; only the cursor was not. The new test asserts the cursor's block is
blank, and that typing lands on the correct side of the quote, so a fix
that freed the cursor by inverting the position would not satisfy it.
Also re-measures item 136 in the backlog, which is not the intermittent
race it was filed as: undoMovesTheMessageBack fails 6 runs in 6 when named
alone and passes in the full suite, on a clean tree, so it depends on the
tests before it.
|
|
Task 13 of item 123, the documentation that was left when the code was
finished.
The README claimed "sending is not implemented. Compose, reply, forward and
send are planned for v2 and need a companion send script that does not exist
yet", which had been false for three days. It also documented none of
`trash`, `send_command` or the `[compose]` section: `trash` has been effectively
mandatory since item 103 and its absence breaks Delete, so a user reading only
the README could not configure a working install. Both are item 122, which
closes with this.
Every default in the new [compose] block was read out of config.h rather than
from the prose around it, which was worth doing: send_html defaults to TRUE
and the first draft of this documented it as false, and the key is
autosave_interval_ms rather than the autosave_delay_ms that reads more
naturally.
The v1/v2 language is retired rather than updated. The project follows semver
on its user-visible surface and those labels described a scope split that
stopped being true when compose shipped. CLAUDE.md says so explicitly rather
than deleting the sentence, since the older spec and plan documents still use
the phrase and are historical records: read it there as "before compose".
CLAUDE.md's architecture diagram listed none of the compose units. It does
now, and it distinguishes the classes from the namespaces, because most of
them are namespaces of free functions over values: the markdown, the MIME
assembly and the account-picking are all testable without a widget. There is
no FormatToolbar class, which the first draft of this diagram invented, and
that is the exact mistake the paragraph under it already warns about for
QueryBar and AttachmentBar.
Also records item 148 from the notes: Ctrl+W does not close the composer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q2koFevoSxTLhfexJTZWQd
|
|
The 2026-08-23 pass over the user's notes. Ten rows, eight sections: two of
the ten are duplicates of items already open and get a row saying so rather
than a second entry.
Six of the eight are the compose interface, which had never been looked at by
a human until this week. They are presentation rather than defect, and every
cause is verified in the code rather than copied from the note: one toolbar
carrying formatting, Attach and Send together (142), text buttons where an
editor uses icons (143), a checkbox label that describes a mechanism without
naming it (144), and Cc and Bcc as unconditional form rows (145).
The other two are the message actions. Forward has no button anywhere (139),
and the user's own answer to that is bigger than a missing button: Compose,
Reply and Forward belong over the message pane rather than on a toolbar that
otherwise acts on the list (140), which needs a bar that does not exist yet
(141). 139 closes inside 140 if that is built.
138 is the closest thing to a defect here: every account already configures a
drafts folder and the query row has no generator for it, so the one folder the
composer writes to is the one folder with no button.
Item 21 gains a pointer rather than a plan. The user is drafting the shortcut
table in their own notes and it is the specification this item has been
waiting for, so the entry says to read it and not to propose one. It records
what the draft settles (Ctrl+Return for Send, which closes an open question
from item 123 task 11), the two collisions its proposal creates (Ctrl+R is
restore today, Ctrl+F is coupled to Find moving to `/`), and the trap under
that last one: a `/` registered as a shortcut is dispatched before any
editable widget sees it, so it needs the event filter Return already uses.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q2koFevoSxTLhfexJTZWQd
|
|
The reply family is disabled on mail that arrived at an account with no
send_command, behind a ribbon in MessageView naming the account and the key
to add. save_message is deliberately never disabled: it is the escape hatch
for exactly that case.
The ribbon is a WIDGET in the pane's layout, never markup inside the web
view. Composing HTML from configuration into the one document that renders
input from strangers is the wrong direction, and the header row is already a
widget for the same reason.
Compose itself is disabled only when NO account can send, and that state is
not warned about at startup: an installation with no send_command anywhere
is a valid read-only installation.
Every reply resolves through messageScopeFor(), not threadFor(): a thread
row means the one message its card shows. Replying to a thread is
meaningless; a reply answers a message. The context is built from the
DATABASE rather than the model, the rule Restore already follows, because a
row whose state has not been re-queried carries stale values and a reply
built from one would carry the wrong recipients.
The mail root crosses from the worker as its own signal. There was no route
for it at all: mailRootOf() is file-static in notmuchworker.cpp, and item
124 records that composing a destination from database.path writes into the
Xapian tree under a split index. The test uses NotmuchFixture::splitIndex(),
the only layout where the two accessors disagree.
A thread row's path is RELATIVE to the mail root while a message row's is
absolute, so the account lookup matched nothing and the reply family was
dead on mail from an account that could send. Found by the positive guard
test rather than the negative one, which passed throughout for the wrong
reason.
The quit path checks the failed-save case FIRST. In the ordinary case
nothing is lost by saving; there, saving is what is already not working, so
the dialog says plainly that quitting loses that text rather than offering a
save that will fail again. Both dialogs name the composers, and the ordinary
one asks once whatever the count, because three modals in a row is worse
than a coarse answer. Its wording says drafts already saved stay in the
folder, so Discard cannot read as 'delete my three messages'.
The Save loop holds QPointers, not raw pointers. A deleteLater() posted
while a nested exec() runs IS processed by that nested loop, measured in a
standalone program: the guard nulls before the modal returns. Closing a
composer while the quit dialog is up therefore freed a window the loop then
called saveDraftNow() on, crashing at the exact moment the application
promised to preserve that text.
A compose request that matches nothing clears itself and says so. It was
cleared only on a match, so a message deleted between selection and Reply
left the request armed for the session: Reply did nothing, and the next
ordinary click on that message opened a composer nobody asked for while the
pane stayed blank.
Forward carries the original's attachments, which the context has always had
a field for and nothing ever filled, and seeds its HTML toggle from
[compose] send_html. Only Reply seeds that from the original.
save_message keeps its filename inside the chosen directory and no longer
overwrites a file already there. The check was correct and untested: the
test asserted through Attachment's helpers rather than through the function
production calls, so deleting the containment check outright left it green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvwDptMWxjqhbCmjxwcSZ2
|
|
Found while running the suite during item 123 task 10, and checked rather
than assumed to be unrelated: with the branch's work stashed out, on a clean
tree, it still fails 1 run in 6. A failure that appears during unrelated work
gets blamed on the change in front of it unless someone measures.
Sized ? deliberately. The race is either in the test's wait or in the
Maildir move Delete performs and Undo reverses, and the two have very
different consequences: a test that waits wrongly is noise, while a move
that races is mail landing in the wrong folder, which this document already
records as reaching the mail server.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FXF741wz4SY7j5dqvAxMU5
|
|
MarkdownFormat, task 8 of the compose-and-send plan. Three free functions
over (text, selection start, selection end) returning the new text and the
selection that follows it, so the grammar is tested without a widget.
Three gaps in the plan's draft, each now pinned by a test checked against the
mutation that breaks it:
- QString::lastIndexOf INCLUDES the position it is given, so quoting with the
cursor at the end of a line found that line's newline and quoted the
FOLLOWING one. The draft's fixtures never placed a cursor there.
- A backwards selection was normalised but never tested, so the swap was
unguarded; a right-to-left drag is an ordinary gesture and Qt reports the
anchor after the cursor. normalise() now swaps and clamps in one place.
- A blank line inside a quoted range produced "> " with trailing whitespace,
which editors and mail clients strip anyway. It is written bare.
Two further defects came out of review:
- quote()'s selectionStart was unasserted for any block not starting at line
zero. Hardcoding it to 0 passed all nineteen tests, because the one test
naming the property quoted the first line, where right and wrong coincide.
A wrong selection there means a second press quotes a line the user never
selected, and a following Bold bolds the wrong text.
- A selection splitting a surrogate pair split the character across the
inserted tokens, leaving invalid UTF-16. Not reachable from the toolbar,
where arrow keys and mouse hit-testing both move in whole clusters, but
reachable by any code computing a position arithmetically. normalise()
nudges off a low surrogate; a collapsed cursor moves back on both ends,
since widening would turn "insert an empty pair here" into "wrap the emoji".
The buttons stack rather than toggle: a second Bold press gives ****this****,
and a second Quote press nests. That is what the spec specifies, and the
preserved selection exists so a second press can apply a SECOND token. A
toggle was built during this task at the user's request and reverted on
finding it contradicts the spec at two sites; it is recorded as backlog item
135, where the unanswered question is what replaces bold-then-italic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L
|
|
ComposeContext, task 7 of the compose-and-send plan. Address parsing,
recipient derivation, the References chain, subject prefixing and account
resolution, as free functions over values so they test without a painter.
Recipient derivation was designed from the spec rather than transcribed: the
plan's draft omitted it and its tests could not compile, calling
QVERIFY(config.load(path)) against a void return.
Six defects found in review, each pinned by a test checked against the
mutation that breaks it:
- Message-ids reached GMime bare, and GMime writes an EMPTY header for a bare
addr-spec rather than complaining. In-Reply-To and References both shipped
blank, so every reply would have arrived as an orphan thread with nothing
wrong to see locally. MessageBuilder now brackets on write, in the one place
that composes those headers rather than in each caller.
- internet_address_to_string was called with FALSE for the encode flag, so a
display name carrying a raw newline rendered with the newline intact. That
is a header-injection primitive.
- A reply to the user's own message addressed the user. It now goes to that
message's original recipients, mirroring their To/Cc split, which is what
the Sent view and a follow-up on unanswered mail need.
- A From parsing to no mailbox left To empty, reachable from real mail
("From: Mailer Daemon"). MessageBuilder treats an empty recipient list as
success, so the message would have been handed to the send command with
nobody to deliver to and filed in Sent looking sent.
- The References header was split on whitespace alone, so a client's
non-conformant "<a@x>,<b@y>" became one token and the bracket strip produced
the fabricated id "a@x>,<b@y".
- Reply and forward prefixes were recognised in English only, doubling every
AW:, SV:, WG: and Re[2]: a mixed-locale mailbox receives. Single-letter
spellings are deliberately excluded: with R: recognised, "R: report on Q3"
reads as a prefix and a genuine first reply threads nowhere.
The mailbox-only guard in parseAddressHeader survived its first mutation
check, because removing it still yields no recipients: the invalid GObject
cast makes GMime's own assertion return NULL. That is undefined behaviour
papered over by an assertion G_DISABLE_CHECKS compiles out, so the test now
asserts on the emitted critical rather than on the count. Registering the log
handler on a NULL domain catches nothing; the criticals carry "GLib-GObject"
and "gmime".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoaLBowZ6w1JNx6SEhDP1L
|
|
MessageSender runs the configured command with the message on stdin and
judges the result by its exit status alone. Nothing here waits on the
event loop, so a send does not block the GUI thread; a 1.6MB payload was
probed through a reading stub without deadlocking the pipe buffer.
The command is split and passed to QProcess as a program and an argument
list, never through a shell. A test asserts that by giving the command
shell metacharacters and checking that the marker file a shell would have
created does not exist, so the property fails a mutation rather than
resting on a comment.
Four corrections to the plan's draft. splitCommand handles double quotes
only, so a single-quoted argument splits wrongly and the header now says
so. A crashing command delivers finished(11, CrashExit) and would have
been reported as "exited with status 11", so a crash branch was added. A
command that exits without draining a large stdin emits WriteError before
finished(), which the draft handled correctly and by luck, untested. And
an empty send_command is checked after trimming.
Two contract gaps found in review, both about what this class promises
rather than what it does.
The exactly-once guarantee covers the EMIT, not what a caller receives: a
long-lived sender plus a connect() inside each send accumulates
receivers, and the second result then runs the first send's lambda too,
filing a sent copy of the wrong message. The header now scopes the
promise and requires Qt::SingleShotConnection. The plan's Task 11 call
site already had that flag, sixty-nine lines below the connect and
outside anything a reader would see, so the plan gained a note where
someone retyping it will read it.
And destruction mid-send killed the command with no report, announced
only by a Qt warning: a live SMTP conversation abandoned, possibly
partially delivered, while the user believes it was cancelled. The
destructor now closes stdin, waits a bounded five seconds, and only then
kills. It emits nothing either way, because the outcome after a kill is
genuinely unknown and reporting "not sent" for a message that may have
gone out is the mailsync.sh mistake pointing the other way. Claiming
m_reported before kill() is what makes that true, since kill() delivers
finished(CrashExit), which would otherwise emit exactly that untruth.
No timeout on the send itself: killing a slow but working send is worse
than waiting. Task 10 owns the popup, and deliberately offers no cancel
after commit, so this class promises none either.
Also refreshes the translations Task 5 left out. That gap was invisible
because test_translations builds its rows from the .ts file, so a string
that never entered it is never asserted on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QP2g3b3kuLx6AYFCNEz6UR
|
|
Task 4's code review measured it: a large attachment is read and base64
encoded on the calling thread, and autosave calls build() from a GUI-thread
timer. The directory hang that review found is fixed, but the blocking read
is by design and will be felt in the composer.
Recorded against Task 11 rather than fixed, because nothing in the composer
crosses the worker boundary and adding a second threading model for one call
is worse than the stall.
|
|
Task 2's code review found that the README's sample config documents every
other key, including recently added ones, and has nothing for send_command
or the [compose] section. Without it those keys ship undiscoverable: a user
has no way to learn that sending exists at all.
That is a gap in the plan rather than a deviation by the task, since no task
claimed the README, so it becomes a step in the close-out where the rest of
the documentation is written.
|
|
Thirteen tasks, ninety-nine steps, against the spec committed earlier on this
branch. Written on master so it is readable from either branch; the
implementation goes on compose-and-send.
Every API assumption was verified against this machine rather than written
from memory, which found five things the spec had wrong or unstated:
libcmark-gfm-extensions ships NO pkg-config file although libcmark-gfm does,
so CMake needs find_library beside pkg_check_modules. All three enabled
extensions live in that second library, so finding only the first yields a
build that compiles and silently renders plain CommonMark.
GMime defaults to iso-8859-1, emits no Date or Message-ID unless asked, and
g_mime_text_part_set_text() encodes with whatever charset is set when it is
called, so setting the charset afterwards produces a part labelled utf-8
carrying latin-1 bytes. All three fail only on accented text, which for this
user is every message. The plan builds the content stream directly and
carries a working probe's output as evidence.
MessageNode has no body or date field, so quoting takes a ParsedMessage.
ThreadListModel::messageScopeFor() takes a QModelIndexList rather than a
single index. There is no Config::maildirPath(): the mail root comes from
notmuch_config_get(NOTMUCH_CONFIG_MAIL_ROOT) via a file-static helper in the
worker, and item 124 records that composing a destination from the wrong
root would write into the Xapian tree.
Two spec statements are corrected in the plan rather than followed. It calls
for a new top-level Message menu and one already exists at
mainwindow.cpp:1156. And it requires a shortcut per action, which item 132
changed while this was being planned, so save_message ships without one.
|
|
everyActionHasAShortcut() was written when the action list was short and
every action plausibly deserved a chord. Item 123 adds six more, and under
that rule each one consumes a key sequence whether or not anyone would ever
press it. Rarely-used actions were being given chords to satisfy a test
rather than because a user wanted them.
everyActionIsReachableFromAMenu() is the rule that actually matters, and it
already has the right shape: it is what stops an action shipping invisible,
which is the defect item 103 found when `restore` was reachable by a chord
and by nothing a user could see. Discoverability comes from the menu. A
shortcut is an accelerator for the things done often.
Nothing replaces the deleted test and nothing else needed changing:
showShortcutReference() already prints `(unbound)` for an empty sequence, so
the code anticipated this and only the test forbade it.
Verified rather than assumed: with `tag_rules` unbound in defaultBindings(),
an action that is registered, menu-reachable and carries an icon but has no
chord at all, the full suite passes. Before this commit it failed.
CLAUDE.md's "adding an action is FIVE places" paragraph is updated, including
its count of how many are test-enforced, which drops from four to three.
|
|
Three rows in every state, so nothing reflows: status label, bar,
right-aligned Undo. The bar changes mode rather than place, determinate
and draining during the countdown because that has measurable progress,
indeterminate once send_command starts because a send does not.
Undo stays visible after it disables. A control that vanishes re-lays
out the popup mid-operation, and a greyed one says why cancelling is no
longer possible where an absent one looks like it was never offered.
The status label sizes from the longest string it can hold in the
current language rather than from its content: Italian "Rimozione della
bozza..." is longer than "Removing draft...", so a content-sized label
resizes the popup between stages, which is the jumping the fixed layout
exists to prevent.
Item 134 gains a requirement from this: the extracted widget must expose
both bar modes, not only the indeterminate one MainWindow happens to
need today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
The user asked for Gmail's undo-send, and it answers a question the
spec had left open: what Cancel means during a send.
It means nothing, if offered while send_command is running. Killing an
SMTP client mid-transaction leaves an unknown send, since the message
may have reached the server in full before the kill, and that is worse
than either clean outcome. Moving the cancel window before the command
starts makes Undo mean genuinely nothing happened.
The popup owns the whole operation, countdown through completion,
rather than a countdown popup handing over to a status bar. One widget
changing state in one place, and it keeps the eye-catching surface the
user asked for. Modal to the composer only, so a second composer and
the main window stay usable. No close button and no Escape: during the
countdown a dismissal cannot say whether it means cancel or send now.
send_delay_ms defaults to 5000, and zero skips it.
The test for this asserts a negative: Undo leaves the stub command never
run. A test asserting only that the composer reopened would pass against
a design that ran the command and discarded the result, which is exactly
what the delay exists to prevent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
Two corrections from the user, both of which the spec had wrong.
A failed sent-copy write was put in the main window's status bar, on
the reasoning that the composer closes so the message needs somewhere
persistent. Wrong instinct: the fix for "the window is gone" is a
dialog, not a quieter surface. It is the one failure here that produces
a silent divergence between what the recipient received and what the
local archive holds, and nobody discovers that from a line that showed
for a few seconds. It gets a modal.
A failed autosave stays in the composer but as a persistent banner
rather than a status-area line, since the quit path already escalates
that state to a dialog and depends on it surviving.
Stated as a rule at the head of the section, because the user's point
was general: modal for silent divergence, banner for mid-task, status
bar only for what is already obvious.
Second correction: the composer's busy indicator is not built inline. A
second instance of MainWindow's progress-bar-plus-label pairing is
where a widget class earns itself, and "this codebase builds small UI
inline" describes what the code does rather than justifying repeating
it. Item 134 extracts it and converts MainWindow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
The spec said "disabled with a spinner" without saying where, which
leaves a popup as a reasonable reading of it. A popup is wrong here: it
would be modal over a window that is already disabled, and it can be
dismissed while the operation continues, which is the indicator
ambiguity items 18, 19, 28 and 54 each closed once.
Progress goes in the composer's own status bar, through the three
stages the operation actually has, since a failure filing the sent copy
means something different from a failure sending. The window closing is
the success message.
The indicator is an indeterminate QProgressBar built inline, matching
MainWindow's m_syncProgress rather than factoring out a shared widget:
this codebase builds small UI inline, and two progress bars do not
justify a third class.
Also settles what the staged display implies for a sent-copy write that
fails after a successful send: the composer still closes, because
holding it open for a message already sent invites sending it twice,
and the warning goes to the main window's status bar instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
The spec said "the editor is plain text" and left it there, which reads
as "you are on your own with the syntax". Storage format and editing
affordances are separate decisions and only the first was stated.
The toolbar is text transformation over the markdown source, not
rich-text editing: bold, italic, code, strikethrough, link and quote,
selection-aware, with the cursor landing between the tokens when there
is no selection.
Its shortcuts belong to the composer window's own scope and do not
touch KeyMap, which matters for item 132: the two namespaces should not
be conflated when that rule is revisited.
Live syntax highlighting is a follow-up (item 133) rather than part of
this: agreeing with the grammar about nesting and about code spans is
the expensive half, and it is better judged after living with the
toolbar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
The spec called it a new dependency needing a SlackBuild REQUIRES entry.
It is a new dependency, but /var/log/packages/ shows
cmark-gfm-0.29.0.gfm.13-x86_64-3 with no _danix tag, so it is stock and
REQUIRES lists only non-stock dependencies.
Also records the staleness cost accepted with it: cmark-gfm tracks an
older CommonMark base (0.29 era) than the stock plain cmark (0.31.2).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
Brainstormed with the user. Design only, no code, which is what the
item's #plan-only tag asked for.
The decision that shaped everything: there is no MTA on the machine, so
"an external script on the same model as mailsync.sh" had no model to
copy. Send becomes a per-account send_command taking the message on
stdin, exactly as [sync] command already works, which keeps the
no-network-protocol rule intact without naming an MTA.
An account with no send_command is receive-only by construction, which
is how one of the five accounts is meant to work. Reply, reply-all and
forward are disabled on its mail behind a ribbon that says why.
The body is markdown parsed by cmark-gfm rather than a hand-written
parser for a limited set: the two share no code, so the small one is
deleted wholesale the moment the set widens.
Four new units, three of them widget-free and testable without a
painter. MessageSender is deliberately a separate unit rather than a
method on the composer, so a future outbox wraps the funnel instead of
reworking it.
Item 123's section is replaced by a pointer to the spec, per this
document's own rule for a fully specified item. The brainstorm opened
items 128 to 132, including a review of the every-action-has-a-shortcut
rule, which the user raised: six more actions takes it past the point
where a chord for everything is useful.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|
|
The entry said sending should be an external script on the same model as
mailsync.sh. Measured on the machine, there is no such model to copy: the
fetch side has mbsync and the send side has nothing.
No MTA is installed at all, msmtp and sendmail are both absent, and neomutt
sends over its own built-in SMTP configured per account in
~/.config/neomutt/accounts/*.rc. So the working setup this application
mirrors has no external send path either.
That makes the first question a non-UX one, ahead of everything the note
lists: sending needs either an MTA the user chooses to install and
configure, which is the only shape that keeps the no-network-protocol rule
intact, or a decision to relax that rule. Recorded so the brainstorm does
not assume the tidier answer, since installing and configuring an MTA is
work he has not asked for and the credentials already exist elsewhere.
Also notes that all five accounts already configure a drafts folder, so the
draft half has somewhere to live before anything is decided.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YDq53rMd3AQp7QmcZzpuBM
|