summaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md149
1 files changed, 149 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
index 6beb35b..5d175a5 100644
--- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
+++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md
@@ -111,6 +111,8 @@ taking that too literally.
| 51 | Clicking a subject scrolls the list sideways | presentation | XS | open |
| 52 | `test_querycompleter` fails under Wayland, passes offscreen | testing | XS | **done** |
| 53 | Message rows still read as a table, not as a conversation | presentation | ? | open, unspecified |
+| 54 | A cron sync carries the edits but the count still says pending | correctness | S | **done** |
+| 55 | In a narrow window the message pane is invisible | presentation | XS | open |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -3238,6 +3240,153 @@ to answer for the strip on thread rows, which must not change.
**Do not start any of these from this description.** It says what is and why,
not what was wanted.
+## 54. A cron sync carries the edits but the count still says pending
+
+**Observed (user, 2026-08-08):** "If I apply some edits in the program, then the
+sync runs from crontab, my edits should go through the sync, instead I still see
+`N changes pending`."
+
+**Cause, verified in code.** The edits really do go out; only the count is
+wrong. A tag edit reaches the notmuch index at edit time, so a `notmuch new`
+fired by cron carries it to the mail store exactly as a local sync would. But
+the pending count is cleared in **one place only**: the success branch of the
+local sync-finished handler, `m_pendingTagEdits.clear()` at
+`src/mainwindow.cpp:1719`, reached from the `MailSync` process this window
+started.
+
+The external path never touches it. `onExternalSyncStateChanged()`
+(`src/mainwindow.cpp:1852`) is driven by `SyncMonitor` watching the lock in
+`/proc/locks`. On `State::Idle` it clears `m_externalSyncBusy`, calls
+`flushHeldEdits()`, and shows "Background sync completed. Press Enter in the
+query bar to refresh." (`src/mainwindow.cpp:1901`). It does not clear
+`m_pendingTagEdits`, does not reset `m_unnettablePendingEdits`, and does not
+drain `m_editedAccounts`. So the indicator keeps counting edits that have
+already shipped, until the user runs a sync from the window.
+
+This is a **defect, not an enhancement**, and it is the same class as item 28:
+the indicator exists to answer "is my work safe to quit on", and here it says no
+when the answer is yes. It also feeds the exit prompt (`pendingEditCount()` at
+`src/mainwindow.cpp:173`), so the user is asked to sync on quit for work that a
+cron run already carried. Adjacent to item 49, which made the account set drive
+which channels a sync runs: `m_editedAccounts` is stale in exactly the same way.
+
+**Approach.** Clear the same three pieces of state on an observed external
+`Idle` that a successful local sync clears. Two things make this harder than
+copying the block, and both must be answered before it is written:
+
+- **The monitor sees a lock, not an outcome.** A cron run that fails releases
+ the lock exactly as a successful one does, and the comment at
+ `src/mainwindow.cpp:1715` records the rule that only a *successful* sync may
+ clear the count. An external run's exit status is not observable from
+ `/proc/locks`. Either the count is cleared optimistically on any external
+ release, or `mailsync.sh` grows a status file the window can read. Ask before
+ choosing: the optimistic version can clear a count whose edits a failed cron
+ run did not carry.
+
+ **Resolved 2026-08-09, and the dilemma was false: the script already writes
+ the outcome.** Every run ends with a
+ `===== RUN END: <ts> status=OK =====` or `status=FAILED mbsync=<n>
+ notmuch=<n>` banner in its log (`assets/mailsync.sh:115-117`), which survives
+ the process that wrote it. `MailSync::lastRunOutcome()` reads it, so neither
+ the optimistic clear nor a change to the sync script was needed. The user
+ chose this over both alternatives.
+- **The held-edit race is already solved for the local path and must hold
+ here.** `flushHeldEdits()` is called on the external `Idle` branch too, and it
+ calls `sendThreadTagChange()`, which writes `m_editedAccounts`
+ **synchronously**, while the pending map is written on the worker's queued
+ reply. The local path handles this by snapshotting `m_editedAccounts` before
+ the flush (`src/mainwindow.cpp:1711`) and subtracting only the snapshot.
+ Anything written here has to do the same, or edits made during the cron run
+ are marked as carried by the sync that did not carry them.
+
+**Constraints.**
+
+- `State::Unknown` must not clear anything. It means `/proc/locks` could not be
+ read, so nothing was observed, and the existing code is careful to distinguish
+ that from `Idle`.
+- The local path must keep working unchanged, including the `m_localSyncHoldsLock`
+ early return at `src/mainwindow.cpp:1874`, which exists so a local sync's own
+ lock release is not mistaken for an external one.
+
+**Verification.** `test_mainwindow` can drive `onExternalSyncStateChanged()`
+directly, which is how the existing external-sync tests work, so this does not
+need a real cron run. Assert on the count and on the exit prompt, and mutate:
+the test must fail if the clear is removed. Item 49's account-set behaviour
+needs its own assertion, since a count that reaches zero while
+`m_editedAccounts` stays full would look correct and still sync the wrong
+channels.
+
+**Built 2026-08-09.** `SyncOutcome` and `MailSync::lastRunOutcome()` parse the
+banner; `Config::syncLog()` supplies the path, defaulting to the script's own
+and overridable through a new `[sync] log` key so a test never reads the
+developer's real log. `onExternalSyncStateChanged()` clears the map, the
+unnettable counter and the account set on `Idle` **and** a definite `OK`, before
+`flushHeldEdits()`, matching the local path's ordering. 13 tests across
+`test_mailsync`, `test_config` and `test_mainwindow`; 15/15 binaries green.
+
+Two notes on the verification, both worth more than the passing count:
+
+- **A timing probe endorsed a tail read that was not happening.** The first
+ version of `lastRunOutcomeReadsATailOfAHugeLog` required the call under
+ 100 ms, and it **passed with the seek deleted**, because reading 10 MB is
+ fast either way. It measured nothing, exactly as CLAUDE.md's rendering-probe
+ entry describes. Replaced with an assertion on content: a marker reachable
+ only from the head of a large file must be invisible to a tail read, plus a
+ guard appending a marker within reach to prove the parser can still find one.
+ That version fails when the seek is removed.
+- **Every fixture was invented, and the first batch was wrong.** They wrote the
+ banner as `RUN END: 2026-08-09 10:20:03`, where the script uses
+ `date -Iseconds` (`assets/mailsync.sh:111`) and so emits
+ `2026-08-09T09:20:33+02:00`. The tests passed anyway, because the parser keys
+ on the `===== RUN END:` prefix and the `status=` token and never looks at the
+ timestamp. Found only by reading the user's real log while waiting for a cron
+ run, not by any test. A fixture invented to match the code tests the code
+ against itself. `lastRunOutcomeReadsABannerTheScriptActuallyWrote` now builds
+ the line by running `date -Iseconds` the way the script does, and the parser
+ was confirmed against the real `~/.local/state/mailsync.log`, which it reads
+ as `Ok`.
+- **The ordering claim is not covered by any test.** With no lock file present
+ `aSyncHoldsTheWriteLock()` is false throughout the suite, so `flushHeldEdits()`
+ is a no-op and moving the clear after it changes no result. The ordering is
+ inherited from the local path rather than independently verified; a test for
+ it needs a held lock, the way item 37's tests stage one.
+
+## 55. In a narrow window the message pane is invisible
+
+**Observed (user, 2026-08-08):** "when opening in a squared window, the left
+pane takes the whole width (right pane almost invisible)".
+
+**Cause, verified in code.** The splitter is built at
+`src/mainwindow.cpp:584` with `setStretchFactor(1, 2)` and **no**
+`setSizes()`, and neither pane carries a minimum width. A stretch factor only
+distributes space left over **after** every child's size hint is satisfied. The
+thread view's hint follows its columns, which are set to fixed widths at
+`src/mainwindow.cpp:557-561` and total roughly 886px (28 + 28 + 130 + 180 +
+520), every one of them `Interactive` with `setStretchLastSection(false)` at
+`src/mainwindow.cpp:513`. In a window narrower than that sum plus a usable
+message pane there is no leftover space at all, so the stretch factor never
+applies and the thread view takes essentially everything.
+
+This only bites on first run. Once a splitter position is saved it is restored
+from `window/splitter` (`src/mainwindow.cpp:120-123`), which is why the window
+behaves after the user has dragged it once. Item 1 built that persistence; it
+does not supply a sane starting point.
+
+**Approach.** Give the message view a minimum width so the splitter cannot
+collapse it, and set an initial `setSizes()` proportional to the window rather
+than letting the hints decide. Sizes only apply when nothing is restored, so it
+must go behind the same "no saved state" check as the geometry.
+
+**Constraints.**
+
+- Must not fight the restore path. A `setSizes()` call that runs after
+ `restoreState()` would throw away the user's saved position, which is item 1.
+- A minimum width on the thread view as well would reintroduce the same problem
+ from the other side. Only the message pane needs one.
+- `resize(1200, 800)` at `src/mainwindow.cpp:206` is the default only when no
+ geometry is saved; the reported case is a user-sized window, so widening the
+ default fixes nothing.
+
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering