From 229851f0bb67fa820fa8084e2664c7c70762e508 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 16:10:50 -0700 Subject: [PATCH 1/6] File the open thread to Set Aside and Reply Later with a and l MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web app's topic toolbar keeps its filing hotkeys live while a thread is on screen, so readers press a and l with an email open — and in the TUI those keys silently scrolled the viewport instead. Route a/A/l from the open thread through the same posting action the list uses, when the thread was opened from a list that files (a box or Previously Seen). Search results, bundles, and directly opened topics have no posting row to act on, so the key answers with a notice instead of silence, and the thread help advertises the keys only where they work. --- internal/tui/mail.go | 26 ++++++++++ internal/tui/mail_test.go | 99 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index ac9482a8..72462de4 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -929,6 +929,9 @@ func (v *mailView) HelpBindings() []helpBinding { } if v.inThread { bindings := []helpBinding{{"r", "reply"}, {"f", "forward"}} + if v.canFileOpenThread() { + bindings = append(bindings, helpBinding{"l", "reply later"}, helpBinding{"a", "set aside"}) + } if len(v.entries) > 1 { bindings = append(bindings, helpBinding{"j/k", "next/previous message"}) } @@ -1230,6 +1233,8 @@ func (v *mailView) HandleContentKey(msg tea.KeyPressMsg) tea.Cmd { if v.topicID != 0 { return v.loadForwardContext(v.topicID, v.topicName) } + case "a", "A", "l": + return v.fileOpenThread(msg.String()) case "[": v.moveAttachmentCursor(-1) return nil @@ -2267,6 +2272,27 @@ func (v *mailView) imboxSource() *mail.Source { return nil } +// fileOpenThread files the thread on screen the way the same key files it on the +// list, matching the web app's topic toolbar keeping its hotkeys live while a +// thread is open. Only a thread opened from a filing list — a box or Previously +// Seen — has a posting row to act on: over search results, bundles, and topics +// opened directly the key answers with a notice instead of silence. +func (v *mailView) fileOpenThread(key string) tea.Cmd { + if v.canFileOpenThread() { + return v.handlePostingAction(key) + } + v.notice = "Can't file this thread from here" + return nil +} + +func (v *mailView) canFileOpenThread() bool { + if v.searchActive || v.bundleActive { + return false + } + selected := v.actionList().selectedPosting() + return selected != nil && selected.TopicID == v.topicID +} + func (v *mailView) handlePostingAction(key string) tea.Cmd { selected := v.actionList().selectedPosting() if selected == nil { diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 8d1ea5c4..bc0b77c5 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -606,6 +606,105 @@ func TestMailViewPostingKeysCallExpectedEndpoints(t *testing.T) { } } +func TestMailViewFilesOpenThread(t *testing.T) { + tests := []struct { + name string + key string + boxID int64 + notice string + }{ + {"reply later", "l", 4, "Thread moved to Reply Later"}, + {"set aside", "a", 3, "Thread moved to Set Aside"}, + {"set aside uppercase", "A", 3, "Thread moved to Set Aside"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + done, ok := runCmd(v.HandleContentKey(keyPress(tt.key))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/moves.json" { + t.Errorf("request = %s %s, want POST /postings/moves.json", recorded.method, recorded.path) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { + t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) + } + if recorded.body.BoxID == nil || *recorded.body.BoxID != tt.boxID { + t.Errorf("box_id = %v, want %d", recorded.body.BoxID, tt.boxID) + } + + answer, _ := v.Update(done) + if toast := deliverToView(v, answer); toast != tt.notice { + t.Errorf("toast = %q, want %q", toast, tt.notice) + } + if !v.inThread { + t.Error("filing should keep the thread open, the way the web app stays on the topic") + } + if v.postingIndex(100) != -1 { + t.Error("the filed thread should leave the box list behind the reader") + } + }) + } +} + +func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { + t.Run("search result", func(t *testing.T) { + v := mailWithPostings() + v.searchActive = true + v.searchList.setPostings([]mail.Posting{{ID: 10, TopicID: 100, Name: "Hello world"}}) + v.inThread = true + v.topicID = 100 + + if cmd := v.HandleContentKey(keyPress("a")); cmd != nil { + t.Errorf("a search-opened thread should not file: %#v", runCmd(cmd)) + } + if v.notice != "Can't file this thread from here" { + t.Errorf("notice = %q, want the filing explanation", v.notice) + } + }) + + t.Run("directly opened topic", func(t *testing.T) { + v := mailWithPostings() + v.inThread = true + v.topicID = 555 // opened by URL, not from the selected row + + if cmd := v.HandleContentKey(keyPress("l")); cmd != nil { + t.Errorf("a directly opened thread should not file the selected row: %#v", runCmd(cmd)) + } + if v.notice != "Can't file this thread from here" { + t.Errorf("notice = %q, want the filing explanation", v.notice) + } + }) +} + +func TestMailViewThreadHelpAdvertisesFilingKeys(t *testing.T) { + v := mailWithPostings() + v.inThread = true + v.topicID = 100 + + bindings := fmt.Sprint(v.HelpBindings()) + for _, want := range []string{"reply later", "set aside"} { + if !strings.Contains(bindings, want) { + t.Errorf("thread help = %s, want %q", bindings, want) + } + } + + v.topicID = 555 + bindings = fmt.Sprint(v.HelpBindings()) + for _, missing := range []string{"reply later", "set aside"} { + if strings.Contains(bindings, missing) { + t.Errorf("unfilable thread help = %s, should drop %q", bindings, missing) + } + } +} + func TestMailViewUnseenKeysRestoreSeenAndBubbledUpThreads(t *testing.T) { for _, testCase := range []struct { name string From c667cca61890d108e3cc0113c69056fafbc9f6ad Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 16:23:49 -0700 Subject: [PATCH 2/6] File the open thread by the posting it was opened from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mark-seen that opening an unseen thread triggers can resort the list and, with a cover configured, slide the row under the art and clamp the cursor onto another row — so a cursor-based guard rejected a and l in exactly the reported case. Carry the posting id the thread was opened from and file on that row by id, wherever the list has settled it. --- internal/tui/mail.go | 29 ++++++++++++++++++++--------- internal/tui/mail_test.go | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 72462de4..898a77d5 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -254,6 +254,7 @@ type mailView struct { topicViewport viewport.Model topicContent string topicID int64 + threadPostingID int64 // the posting the open thread was opened from, zero when it has none topicName string entries []mail.Entry attachments []messageAttachment @@ -515,6 +516,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.inThread = true v.topicID = msg.topicID + v.threadPostingID = msg.postingID v.topicName = msg.title v.entries = msg.entries v.attachments = msg.attachments @@ -929,7 +931,7 @@ func (v *mailView) HelpBindings() []helpBinding { } if v.inThread { bindings := []helpBinding{{"r", "reply"}, {"f", "forward"}} - if v.canFileOpenThread() { + if v.fileablePosting() != nil { bindings = append(bindings, helpBinding{"l", "reply later"}, helpBinding{"a", "set aside"}) } if len(v.entries) > 1 { @@ -1412,6 +1414,7 @@ func (v *mailView) ExitThread() { if v.inThread { v.inThread = false v.threadNotice = "" + v.threadPostingID = 0 v.modal = nil v.requests.cancel() return @@ -1592,6 +1595,7 @@ func (v *mailView) switchBox(index int) tea.Cmd { } v.inThread = false v.threadNotice = "" + v.threadPostingID = 0 v.clearSearch() v.clearBundle() v.clearSeen() @@ -1613,6 +1617,7 @@ func (v *mailView) openPreviouslySeen() tea.Cmd { } v.inThread = false v.threadNotice = "" + v.threadPostingID = 0 v.clearSearch() v.clearBundle() v.notice = "" @@ -2278,19 +2283,22 @@ func (v *mailView) imboxSource() *mail.Source { // Seen — has a posting row to act on: over search results, bundles, and topics // opened directly the key answers with a notice instead of silence. func (v *mailView) fileOpenThread(key string) tea.Cmd { - if v.canFileOpenThread() { - return v.handlePostingAction(key) + if posting := v.fileablePosting(); posting != nil { + return v.postingAction(key, *posting) } v.notice = "Can't file this thread from here" return nil } -func (v *mailView) canFileOpenThread() bool { - if v.searchActive || v.bundleActive { - return false +// fileablePosting is the row the open thread files on: the posting the thread was +// opened from, found by id rather than under the cursor because the mark-seen that +// opening triggers can resort the list, slide the row under the cover, and clamp +// the cursor onto some other row while the thread is on screen. +func (v *mailView) fileablePosting() *mail.Posting { + if v.searchActive || v.bundleActive || v.threadPostingID == 0 { + return nil } - selected := v.actionList().selectedPosting() - return selected != nil && selected.TopicID == v.topicID + return v.openedPosting(v.threadPostingID) } func (v *mailView) handlePostingAction(key string) tea.Cmd { @@ -2298,7 +2306,10 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { if selected == nil { return nil } - p := *selected + return v.postingAction(key, *selected) +} + +func (v *mailView) postingAction(key string, p mail.Posting) tea.Cmd { boxID := v.currentBoxID() switch key { diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index bc0b77c5..f7fdb062 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -654,6 +654,33 @@ func TestMailViewFilesOpenThread(t *testing.T) { } } +func TestMailViewFilesOpenThreadAfterMarkSeenCoversItsRow(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.postingList.setCover(coverTopo) + + opened, _ := v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + // The mark-seen that opening triggers slides the row under the cover and + // clamps the cursor off it, so filing cannot go through the selection. + deliverToView(v, opened) + if p := v.actionList().selectedPosting(); p != nil && p.ID == 100 { + t.Fatal("mark-seen under a cover should move the cursor off the opened row") + } + + done, ok := runCmd(v.HandleContentKey(keyPress("a"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/moves.json" { + t.Errorf("request = %s %s, want POST /postings/moves.json", recorded.method, recorded.path) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { + t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) + } +} + func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { t.Run("search result", func(t *testing.T) { v := mailWithPostings() @@ -661,6 +688,7 @@ func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { v.searchList.setPostings([]mail.Posting{{ID: 10, TopicID: 100, Name: "Hello world"}}) v.inThread = true v.topicID = 100 + v.threadPostingID = 10 if cmd := v.HandleContentKey(keyPress("a")); cmd != nil { t.Errorf("a search-opened thread should not file: %#v", runCmd(cmd)) @@ -673,7 +701,7 @@ func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { t.Run("directly opened topic", func(t *testing.T) { v := mailWithPostings() v.inThread = true - v.topicID = 555 // opened by URL, not from the selected row + v.topicID = 555 // opened by URL, with no posting row behind it if cmd := v.HandleContentKey(keyPress("l")); cmd != nil { t.Errorf("a directly opened thread should not file the selected row: %#v", runCmd(cmd)) @@ -688,6 +716,7 @@ func TestMailViewThreadHelpAdvertisesFilingKeys(t *testing.T) { v := mailWithPostings() v.inThread = true v.topicID = 100 + v.threadPostingID = 100 bindings := fmt.Sprint(v.HelpBindings()) for _, want := range []string{"reply later", "set aside"} { @@ -696,7 +725,7 @@ func TestMailViewThreadHelpAdvertisesFilingKeys(t *testing.T) { } } - v.topicID = 555 + v.threadPostingID = 0 // opened by URL, with no posting row behind it bindings = fmt.Sprint(v.HelpBindings()) for _, missing := range []string{"reply later", "set aside"} { if strings.Contains(bindings, missing) { From c740c3a1057a75d507fbaf415b7d8f8eeea9c72f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 16:36:19 -0700 Subject: [PATCH 3/6] Snapshot the opened posting instead of finding it in the list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live refresh whose head no longer returns the opened row takes it out of the list entirely — refreshHead removes the old head row rather than merely resorting it — so the by-id lookup came up empty while the thread was still on screen. Snapshot the posting when the thread opens and file on that, independent of whatever the list has since done with the row. --- internal/tui/mail.go | 29 +++++++++++++++++----------- internal/tui/mail_test.go | 40 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 898a77d5..1fff89b2 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -254,7 +254,7 @@ type mailView struct { topicViewport viewport.Model topicContent string topicID int64 - threadPostingID int64 // the posting the open thread was opened from, zero when it has none + threadPosting mail.Posting // snapshot of the posting the open thread was opened from, zero when it has none topicName string entries []mail.Entry attachments []messageAttachment @@ -516,7 +516,14 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { } v.inThread = true v.topicID = msg.topicID - v.threadPostingID = msg.postingID + // The posting the thread was opened from, snapshotted rather than looked up + // again later: the automatic mark-seen and the live refresh can resort the + // row, slide it under the cover, or drop it off the head page while the + // thread stays on screen. + v.threadPosting = mail.Posting{ID: msg.postingID, TopicID: msg.topicID} + if opened := v.openedPosting(msg.postingID); opened != nil { + v.threadPosting = *opened + } v.topicName = msg.title v.entries = msg.entries v.attachments = msg.attachments @@ -1414,7 +1421,7 @@ func (v *mailView) ExitThread() { if v.inThread { v.inThread = false v.threadNotice = "" - v.threadPostingID = 0 + v.threadPosting = mail.Posting{} v.modal = nil v.requests.cancel() return @@ -1595,7 +1602,7 @@ func (v *mailView) switchBox(index int) tea.Cmd { } v.inThread = false v.threadNotice = "" - v.threadPostingID = 0 + v.threadPosting = mail.Posting{} v.clearSearch() v.clearBundle() v.clearSeen() @@ -1617,7 +1624,7 @@ func (v *mailView) openPreviouslySeen() tea.Cmd { } v.inThread = false v.threadNotice = "" - v.threadPostingID = 0 + v.threadPosting = mail.Posting{} v.clearSearch() v.clearBundle() v.notice = "" @@ -2290,15 +2297,15 @@ func (v *mailView) fileOpenThread(key string) tea.Cmd { return nil } -// fileablePosting is the row the open thread files on: the posting the thread was -// opened from, found by id rather than under the cursor because the mark-seen that -// opening triggers can resort the list, slide the row under the cover, and clamp -// the cursor onto some other row while the thread is on screen. +// fileablePosting is the posting the open thread files on: the snapshot taken when +// the thread opened, standing in for a row the list may no longer hold — the +// automatic mark-seen resorts it under the cover and clamps the cursor away, and a +// live refresh can drop it off the head page — while the thread stays on screen. func (v *mailView) fileablePosting() *mail.Posting { - if v.searchActive || v.bundleActive || v.threadPostingID == 0 { + if v.searchActive || v.bundleActive || v.threadPosting.ID == 0 { return nil } - return v.openedPosting(v.threadPostingID) + return &v.threadPosting } func (v *mailView) handlePostingAction(key string) tea.Cmd { diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index f7fdb062..807b711b 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -681,6 +681,40 @@ func TestMailViewFilesOpenThreadAfterMarkSeenCoversItsRow(t *testing.T) { } } +func TestMailViewFilesOpenThreadAfterLiveRefreshDropsItsRow(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + // A live refresh whose head no longer returns the opened row takes it out of + // the list entirely, so filing cannot go looking for it there. + v.Update(postingsRefreshedMsg{ + requestID: v.liveRequestID, + boxID: v.currentBoxID(), + sourceKind: v.currentSourceKind(), + postings: testPostings()[1:], + }) + if v.postingIndex(100) != -1 { + t.Fatal("test needs the refresh to drop the opened row from the list") + } + if bindings := fmt.Sprint(v.HelpBindings()); !strings.Contains(bindings, "set aside") { + t.Errorf("thread help = %s, should keep advertising filing", bindings) + } + + done, ok := runCmd(v.HandleContentKey(keyPress("l"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.method != http.MethodPost || recorded.path != "/postings/moves.json" { + t.Errorf("request = %s %s, want POST /postings/moves.json", recorded.method, recorded.path) + } + if len(recorded.body.PostingIDs) != 1 || recorded.body.PostingIDs[0] != 100 { + t.Errorf("posting_ids = %v, want [100]", recorded.body.PostingIDs) + } +} + func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { t.Run("search result", func(t *testing.T) { v := mailWithPostings() @@ -688,7 +722,7 @@ func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { v.searchList.setPostings([]mail.Posting{{ID: 10, TopicID: 100, Name: "Hello world"}}) v.inThread = true v.topicID = 100 - v.threadPostingID = 10 + v.threadPosting = mail.Posting{ID: 10, TopicID: 100} if cmd := v.HandleContentKey(keyPress("a")); cmd != nil { t.Errorf("a search-opened thread should not file: %#v", runCmd(cmd)) @@ -716,7 +750,7 @@ func TestMailViewThreadHelpAdvertisesFilingKeys(t *testing.T) { v := mailWithPostings() v.inThread = true v.topicID = 100 - v.threadPostingID = 100 + v.threadPosting = mail.Posting{ID: 100, TopicID: 100} bindings := fmt.Sprint(v.HelpBindings()) for _, want := range []string{"reply later", "set aside"} { @@ -725,7 +759,7 @@ func TestMailViewThreadHelpAdvertisesFilingKeys(t *testing.T) { } } - v.threadPostingID = 0 // opened by URL, with no posting row behind it + v.threadPosting = mail.Posting{} // opened by URL, with no posting row behind it bindings = fmt.Sprint(v.HelpBindings()) for _, missing := range []string{"reply later", "set aside"} { if strings.Contains(bindings, missing) { From f38ff8afa733c24d6f27f9cdd774b7c607909320 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 16:51:12 -0700 Subject: [PATCH 4/6] Measure filing against the box the open thread lives in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A move keeps the thread on screen, so a second filing key was still compared against the list under it: file a Set Aside thread to Reply Later and a then answered "Already in Set Aside" while the server had it in Reply Later. Pass the box kind a posting files out of down to the move — the list's own box for a row, the tracked kind for the open thread — and follow the thread's kind as successful moves land it somewhere new. --- internal/tui/mail.go | 86 +++++++++++++++++++++++++-------------- internal/tui/mail_test.go | 42 +++++++++++++++++++ 2 files changed, 97 insertions(+), 31 deletions(-) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 1fff89b2..b0000126 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -194,13 +194,14 @@ const ( ) type postingActionDoneMsg struct { - action string - boxID int64 - sourceKind mail.Kind - postingID int64 - effect postingActionEffect - seen bool // the action was taken on the Previously Seen screen - err error + action string + boxID int64 + sourceKind mail.Kind + postingID int64 + effect postingActionEffect + destinationKind string // the box kind a move filed into, empty for every other action + seen bool // the action was taken on the Previously Seen screen + err error } // postingSeenMsg reports the mark-seen that opening a thread triggers on its @@ -255,6 +256,7 @@ type mailView struct { topicContent string topicID int64 threadPosting mail.Posting // snapshot of the posting the open thread was opened from, zero when it has none + threadBoxKind string // the box kind the open thread files out of, following it as filings move it topicName string entries []mail.Entry attachments []messageAttachment @@ -524,6 +526,7 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { if opened := v.openedPosting(msg.postingID); opened != nil { v.threadPosting = *opened } + v.threadBoxKind = v.actionBoxKind() v.topicName = msg.title v.entries = msg.entries v.attachments = msg.attachments @@ -696,6 +699,11 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { case postingActionDoneMsg: v.finishMutation() + // A move of the open thread leaves it on screen in its new box, so later + // filing keys measure against where it landed, not where it was opened. + if msg.err == nil && v.inThread && msg.postingID == v.threadPosting.ID && msg.destinationKind != "" { + v.threadBoxKind = msg.destinationKind + } if msg.seen { return v.applySeenPostingAction(msg), true } @@ -1422,6 +1430,7 @@ func (v *mailView) ExitThread() { v.inThread = false v.threadNotice = "" v.threadPosting = mail.Posting{} + v.threadBoxKind = "" v.modal = nil v.requests.cancel() return @@ -1603,6 +1612,7 @@ func (v *mailView) switchBox(index int) tea.Cmd { v.inThread = false v.threadNotice = "" v.threadPosting = mail.Posting{} + v.threadBoxKind = "" v.clearSearch() v.clearBundle() v.clearSeen() @@ -1625,6 +1635,7 @@ func (v *mailView) openPreviouslySeen() tea.Cmd { v.inThread = false v.threadNotice = "" v.threadPosting = mail.Posting{} + v.threadBoxKind = "" v.clearSearch() v.clearBundle() v.notice = "" @@ -2291,7 +2302,7 @@ func (v *mailView) imboxSource() *mail.Source { // opened directly the key answers with a notice instead of silence. func (v *mailView) fileOpenThread(key string) tea.Cmd { if posting := v.fileablePosting(); posting != nil { - return v.postingAction(key, *posting) + return v.postingAction(key, *posting, v.threadBoxKind) } v.notice = "Can't file this thread from here" return nil @@ -2313,21 +2324,34 @@ func (v *mailView) handlePostingAction(key string) tea.Cmd { if selected == nil { return nil } - return v.postingAction(key, *selected) + return v.postingAction(key, *selected, v.actionBoxKind()) } -func (v *mailView) postingAction(key string, p mail.Posting) tea.Cmd { +// actionBoxKind is the box kind a list row files out of, empty over a source that +// is not one of HEY's own boxes. +func (v *mailView) actionBoxKind() string { + if source := v.actionSource(); source != nil { + return source.BoxKind + } + return "" +} + +// postingAction runs key's action on p. fromBoxKind is the box kind the posting +// files out of — the list's own box for a row, the box the open thread lives in +// for a filing key pressed there — so a move to the box it is already in answers +// with a notice instead of a request. +func (v *mailView) postingAction(key string, p mail.Posting, fromBoxKind string) tea.Cmd { boxID := v.currentBoxID() switch key { // Only lowercase moves to Reply Later: Shift+L navigates to Labels, the // way Shift+K reaches Collections. case "l": - return v.moveSelectedToKnownBox("Reply Later", hey.BoxKindLater, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("Reply Later", hey.BoxKindLater, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToReplyLater(v.vc.ctx, p.ID) }) case "a", "A": - return v.moveSelectedToKnownBox("Set Aside", hey.BoxKindSetAside, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("Set Aside", hey.BoxKindSetAside, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToSetAside(v.vc.ctx, p.ID) }) case "e", "E": @@ -2347,13 +2371,13 @@ func (v *mailView) postingAction(key string, p mail.Posting) tea.Cmd { return v.vc.sdk.Postings().MarkUnseen(v.vc.ctx, []int64{p.ID}) }) case "i", "I": - return v.moveSelectedToImbox(boxID, p.ID) + return v.moveSelectedToImbox(fromBoxKind, boxID, p.ID) case "d", "D": - return v.moveSelectedToKnownBox("The Feed", hey.BoxKindFeed, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("The Feed", hey.BoxKindFeed, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToFeed(v.vc.ctx, p.ID) }) case "p", "P": - return v.moveSelectedToKnownBox("Paper Trail", hey.BoxKindTrail, boxID, p.ID, func() error { + return v.moveSelectedToKnownBox("Paper Trail", hey.BoxKindTrail, fromBoxKind, boxID, p.ID, func() error { return v.vc.sdk.Postings().MoveToPaperTrail(v.vc.ctx, p.ID) }) case "t", "T": @@ -2394,10 +2418,10 @@ func (v *mailView) postingAction(key string, p mail.Posting) tea.Cmd { return nil } -func (v *mailView) moveSelectedToImbox(boxID, postingID int64) tea.Cmd { +func (v *mailView) moveSelectedToImbox(fromBoxKind string, boxID, postingID int64) tea.Cmd { if source := v.imboxSource(); source != nil { imboxID := source.ID - return v.moveSelectedToKnownBox("Imbox", hey.BoxKindImbox, boxID, postingID, func() error { + return v.moveSelectedToKnownBox("Imbox", hey.BoxKindImbox, fromBoxKind, boxID, postingID, func() error { return v.vc.sdk.Postings().Move(v.vc.ctx, imboxID, postingID) }) } @@ -2405,12 +2429,23 @@ func (v *mailView) moveSelectedToImbox(boxID, postingID int64) tea.Cmd { return nil } -func (v *mailView) moveSelectedToKnownBox(name, kind string, boxID, postingID int64, fn func() error) tea.Cmd { - if !v.movesOutOfCurrentBox(kind) { +func (v *mailView) moveSelectedToKnownBox(name, kind, fromBoxKind string, boxID, postingID int64, fn func() error) tea.Cmd { + // The destination is one of HEY's own box kinds, so the posting's kind answers + // whether the move would do anything — a label or a collection carries none and + // is never the destination. + if fromBoxKind == kind { v.notice = "Already in " + name return nil } - return v.doPostingAction("Thread moved to "+name, v.boxMoveEffect(), boxID, postingID, fn) + move := v.doPostingAction("Thread moved to "+name, v.boxMoveEffect(), boxID, postingID, fn) + return func() tea.Msg { + done, ok := move().(postingActionDoneMsg) + if !ok { + return nil + } + done.destinationKind = kind + return done + } } func (v *mailView) boxMoveEffect() postingActionEffect { @@ -2423,17 +2458,6 @@ func (v *mailView) boxMoveEffect() postingActionEffect { return postingActionRemove } -// movesOutOfCurrentBox reports whether a key that files a thread somewhere would move it -// at all. The destination is one of HEY's own box kinds, so it is the box's kind that -// answers — a label or a collection carries none and is never the destination. -func (v *mailView) movesOutOfCurrentBox(destinationBoxKind string) bool { - source := v.actionSource() - if source == nil { - return true - } - return source.BoxKind != destinationBoxKind -} - func (v *mailView) doPostingAction(label string, effect postingActionEffect, boxID, postingID int64, fn func() error) tea.Cmd { sourceKind := v.currentSourceKind() seen := v.seenActive diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 807b711b..9849d247 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -715,6 +715,48 @@ func TestMailViewFilesOpenThreadAfterLiveRefreshDropsItsRow(t *testing.T) { } } +func TestMailViewFilesOpenThreadWhereItLandedLastTime(t *testing.T) { + v, recorded := mailWithTestServer(t, http.StatusNoContent) + v.boxes = append(v.boxes, mail.Source{Kind: mail.KindBox, ID: 3, Name: "Set Aside", BoxKind: hey.BoxKindSetAside}) + v.boxIndex = len(v.boxes) - 1 + v.Update(currentPostingsLoaded(v, testPostings())) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + // The thread was opened from Set Aside, so a answers without a request. + if cmd := v.HandleContentKey(keyPress("a")); cmd != nil { + t.Errorf("filing to the box the thread is in should not move: %#v", runCmd(cmd)) + } + if v.notice != "Already in Set Aside" { + t.Errorf("notice = %q, want %q", v.notice, "Already in Set Aside") + } + + // Filing to Reply Later moves the thread there, and later keys measure + // against where it landed: l answers in place, a moves it back. + done, ok := runCmd(v.HandleContentKey(keyPress("l"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + v.Update(done) + + if cmd := v.HandleContentKey(keyPress("l")); cmd != nil { + t.Errorf("filing to the box the thread landed in should not move: %#v", runCmd(cmd)) + } + if v.notice != "Already in Reply Later" { + t.Errorf("notice = %q, want %q", v.notice, "Already in Reply Later") + } + + done, ok = runCmd(v.HandleContentKey(keyPress("a"))).(postingActionDoneMsg) + if !ok || done.err != nil { + t.Fatalf("filing command returned %#v", done) + } + if recorded.body.BoxID == nil || *recorded.body.BoxID != 3 { + t.Errorf("box_id = %v, want 3", recorded.body.BoxID) + } +} + func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { t.Run("search result", func(t *testing.T) { v := mailWithPostings() From c4748bed77b9c5aa48a385e36c4d27581801498f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 17:03:26 -0700 Subject: [PATCH 5/6] Let only the latest filing record where the thread landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filing keys pressed faster than their requests answer complete in whatever order the responses arrive, so the last response could record a box the server had already filed past. Stamp each open-thread filing with a dispatch sequence and record the landing box only from the latest one — a failed or superseded move records nothing. --- internal/tui/mail.go | 33 +++++++++++++++++++++++++++------ internal/tui/mail_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index b0000126..a3063087 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -200,6 +200,7 @@ type postingActionDoneMsg struct { postingID int64 effect postingActionEffect destinationKind string // the box kind a move filed into, empty for every other action + filingSeq uint64 // which open-thread filing dispatched the move, zero for a list row's seen bool // the action was taken on the Previously Seen screen err error } @@ -257,6 +258,7 @@ type mailView struct { topicID int64 threadPosting mail.Posting // snapshot of the posting the open thread was opened from, zero when it has none threadBoxKind string // the box kind the open thread files out of, following it as filings move it + threadFilingSeq uint64 // dispatch order of open-thread filings, so only the latest records where the thread landed topicName string entries []mail.Entry attachments []messageAttachment @@ -700,8 +702,10 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { case postingActionDoneMsg: v.finishMutation() // A move of the open thread leaves it on screen in its new box, so later - // filing keys measure against where it landed, not where it was opened. - if msg.err == nil && v.inThread && msg.postingID == v.threadPosting.ID && msg.destinationKind != "" { + // filing keys measure against where it landed, not where it was opened — + // and only the latest dispatched filing gets to say where that is. + if msg.err == nil && v.inThread && msg.postingID == v.threadPosting.ID && + msg.destinationKind != "" && msg.filingSeq == v.threadFilingSeq { v.threadBoxKind = msg.destinationKind } if msg.seen { @@ -2301,11 +2305,28 @@ func (v *mailView) imboxSource() *mail.Source { // Seen — has a posting row to act on: over search results, bundles, and topics // opened directly the key answers with a notice instead of silence. func (v *mailView) fileOpenThread(key string) tea.Cmd { - if posting := v.fileablePosting(); posting != nil { - return v.postingAction(key, *posting, v.threadBoxKind) + posting := v.fileablePosting() + if posting == nil { + v.notice = "Can't file this thread from here" + return nil + } + move := v.postingAction(key, *posting, v.threadBoxKind) + if move == nil { + return nil + } + // Filing keys pressed faster than their requests answer can complete out of + // order, so each dispatch takes a sequence number and only the latest one + // records where the thread landed. + v.threadFilingSeq++ + seq := v.threadFilingSeq + return func() tea.Msg { + msg := move() + if done, ok := msg.(postingActionDoneMsg); ok { + done.filingSeq = seq + return done + } + return msg } - v.notice = "Can't file this thread from here" - return nil } // fileablePosting is the posting the open thread files on: the snapshot taken when diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index 9849d247..d0babfb6 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -757,6 +757,37 @@ func TestMailViewFilesOpenThreadWhereItLandedLastTime(t *testing.T) { } } +func TestMailViewFilesOpenThreadRecordsOnlyTheLatestFiling(t *testing.T) { + v, _ := mailWithTestServer(t, http.StatusNoContent) + v.Update(runCmd(v.HandleContentKey(keyPress("enter")))) + if !v.inThread { + t.Fatal("enter should open the selected thread") + } + + // Two filing keys faster than their requests answer, with the responses + // crossing: the first-dispatched move answers last. Only the latest + // dispatch records where the thread landed. + aside := v.HandleContentKey(keyPress("a")) + later := v.HandleContentKey(keyPress("l")) + laterDone, ok := runCmd(later).(postingActionDoneMsg) + if !ok || laterDone.err != nil { + t.Fatalf("filing command returned %#v", laterDone) + } + asideDone, ok := runCmd(aside).(postingActionDoneMsg) + if !ok || asideDone.err != nil { + t.Fatalf("filing command returned %#v", asideDone) + } + v.Update(laterDone) + v.Update(asideDone) + + if cmd := v.HandleContentKey(keyPress("l")); cmd != nil { + t.Errorf("the thread landed in Reply Later, so l should answer in place: %#v", runCmd(cmd)) + } + if v.notice != "Already in Reply Later" { + t.Errorf("notice = %q, want %q", v.notice, "Already in Reply Later") + } +} + func TestMailViewFilesOpenThreadOnlyFromFilingLists(t *testing.T) { t.Run("search result", func(t *testing.T) { v := mailWithPostings() From 0224bdee84d2fc587bdb08a9c008ce56d2d0596d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 31 Aug 2026 17:15:40 -0700 Subject: [PATCH 6/6] Re-read the box head when the open thread files back into it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filing the open thread out and back — Set Aside to Reply Later and home again — removed its row on the way out, and the return had nothing to reinsert, leaving the on-screen list missing a thread the server holds. When a move lands in the box on screen and the row is gone, re-read the head through the live-refresh lane instead of reconstructing the row from a stale snapshot. --- internal/tui/mail.go | 6 ++++++ internal/tui/mail_test.go | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/internal/tui/mail.go b/internal/tui/mail.go index a3063087..a2d08ab3 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -734,6 +734,12 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { v.postingList.postings[idx].Muted = false } } + // The open thread can file back into the box on screen — out and back while + // it stays open — and its row was removed when it first filed away, so the + // list re-reads its head to hold what the server now does. + if msg.destinationKind != "" && msg.destinationKind == v.actionBoxKind() && v.postingIndex(msg.postingID) < 0 { + return tea.Batch(done, v.refreshBox(msg.boxID)), true + } if v.requests.kind == mailRequestPostings { if source := v.currentSource(); source != nil { return tea.Batch(done, v.requestPostings(*source)), true diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go index d0babfb6..27cb6956 100644 --- a/internal/tui/mail_test.go +++ b/internal/tui/mail_test.go @@ -755,6 +755,22 @@ func TestMailViewFilesOpenThreadWhereItLandedLastTime(t *testing.T) { if recorded.body.BoxID == nil || *recorded.body.BoxID != 3 { t.Errorf("box_id = %v, want 3", recorded.body.BoxID) } + + // Landing back in the box on screen, whose row was removed when the thread + // first filed away, re-reads the head so the list holds what the server does. + restore, _ := v.Update(done) + if restore == nil { + t.Fatal("filing back into the box on screen should re-read its head") + } + v.Update(postingsRefreshedMsg{ + requestID: v.liveRequestID, + boxID: v.currentBoxID(), + sourceKind: v.currentSourceKind(), + postings: testPostings(), + }) + if v.postingIndex(100) == -1 { + t.Error("the re-read head should put the returned thread back on the list") + } } func TestMailViewFilesOpenThreadRecordsOnlyTheLatestFiling(t *testing.T) {