From 0fc5ed6233a596f55c6ff269aacde7a87177a428 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 29 Sep 2026 21:19:12 +0200 Subject: [PATCH] Return to the list after sending a reply or forward from a thread Co-Authored-By: Claude Opus 5.5 --- docs/tui.md | 2 ++ internal/tui/compose.go | 3 +++ internal/tui/compose_test.go | 52 +++++++++++++++++++++++++++++++++--- internal/tui/mail.go | 6 +++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index b95d8102..85c50790 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -64,6 +64,8 @@ uppercase belongs to Labels: A thread opens on its latest message; `k` steps back through the ones before it and `j` forward again. +Sending a reply or a forward from a thread closes the thread and returns you to the list you opened it from — the Imbox, a search, a bundle. A send that fails keeps the form open. + While reading a thread, the From header shows the actual send-as address when HEY records one separately from the account user. While reading a thread, links can be selected without a mouse. Tab selects the next link in document order and Shift+Tab selects the previous one; both wrap at the ends. A fixed row above the shortcut bar shows the complete selected destination without moving the thread. Press Enter to open it; opening stays unavailable until the terminal is wide enough to show the complete destination. Press Escape once to clear the selection, and Escape again to leave the thread. A thread with no selectable links keeps the normal global Tab focus behavior. Existing OSC 8 mouse links remain available. diff --git a/internal/tui/compose.go b/internal/tui/compose.go index fb7c7d7a..31e87cad 100644 --- a/internal/tui/compose.go +++ b/internal/tui/compose.go @@ -82,6 +82,7 @@ const ( type composeForm struct { mode composeMode topicName string + topicID int64 // the thread a reply or forward was written from; 0 for a new message entryID int64 // reply target (composeReply only) replySubject string // the "Re: …" subject a reply goes out under (composeReply only) replyActingSenderID int64 // the sender a reply goes out as; 0 = account default (composeReply only) @@ -140,6 +141,7 @@ func placeholderFor(label string) string { func newReplyForm(ctxMsg replyContextLoadedMsg, s styles) *composeForm { f := newComposeForm(composeReply, s) f.topicName = ctxMsg.topicName + f.topicID = ctxMsg.topicID f.entryID = ctxMsg.entryID f.replySubject = ctxMsg.subject f.replyActingSenderID = ctxMsg.actingSenderID @@ -154,6 +156,7 @@ func newReplyForm(ctxMsg replyContextLoadedMsg, s styles) *composeForm { func newForwardForm(ctxMsg forwardContextLoadedMsg, s styles) *composeForm { f := newComposeForm(composeForward, s) f.topicName = ctxMsg.topicName + f.topicID = ctxMsg.topicID f.sendSDK = ctxMsg.sdk f.forwardedContent = ctxMsg.content f.inputs[fieldSubject].SetValue(ctxMsg.subject) diff --git a/internal/tui/compose_test.go b/internal/tui/compose_test.go index 4866e802..ee5054d0 100644 --- a/internal/tui/compose_test.go +++ b/internal/tui/compose_test.go @@ -496,13 +496,16 @@ func TestForwardFormLoadsLatestEntryAndSends(t *testing.T) { // Sending from inside a thread used to leave its confirmation in the posting list's // header, which a thread covers — so it was said to nobody. A toast belongs to the -// model and is drawn over whatever the section is showing. -func TestForwardCompletionIsSaidFromInsideAThread(t *testing.T) { +// model and is drawn over whatever the section is showing. The send also finishes with +// the thread, so the reader is back on the list they opened it from. +func TestForwardFromAThreadReturnsToTheList(t *testing.T) { v := mailWithPostings() v.Resize(80, 30) v.inThread = true + v.topicID = 123 v.topicViewport.SetContent("Original thread") v.modal = newForwardForm(forwardContextLoadedMsg{ + topicID: 123, topicName: "Quarterly planning", subject: "Fwd: Quarterly planning", content: "
Quoted message
", @@ -516,8 +519,49 @@ func TestForwardCompletionIsSaidFromInsideAThread(t *testing.T) { if toast := deliverToView(v, answer); toast != "Message forwarded" { t.Errorf("toast = %q", toast) } - if view := v.View(); !strings.Contains(view, "Original thread") { - t.Errorf("the thread should still be on screen, got %q", view) + if v.inThread { + t.Error("the thread should close once the forward is sent") + } + if view := v.View(); strings.Contains(view, "Original thread") { + t.Errorf("the list should be back on screen, got %q", view) + } +} + +func TestReplyFromAThreadReturnsToTheList(t *testing.T) { + v := mailWithPostings() + v.Resize(80, 30) + v.inThread = true + v.topicID = 7 + v.modal = newReplyForm(replyContextLoadedMsg{ + boxID: 1, topicID: 7, topicName: "Kitchen renovation", entryID: 99, subject: "Re: Kitchen renovation", + to: []string{"jane.cooper@example.com"}, + }, v.vc.styles) + + v.Update(composeSentMsg{label: "Reply sent"}) + + if v.inThread { + t.Error("the thread should close once the reply is sent") + } + if composeModal(v) != nil { + t.Error("reply form should close after sending") + } +} + +// A failed send keeps the reader in the thread with the form open, so nothing is lost. +func TestFailedReplyStaysInTheThread(t *testing.T) { + v := mailWithPostings() + v.Resize(80, 30) + v.inThread = true + v.topicID = 7 + v.modal = newReplyForm(replyContextLoadedMsg{ + boxID: 1, topicID: 7, topicName: "Kitchen renovation", entryID: 99, subject: "Re: Kitchen renovation", + to: []string{"jane.cooper@example.com"}, + }, v.vc.styles) + + v.Update(composeSentMsg{label: "Reply sent", err: io.ErrUnexpectedEOF}) + + if !v.inThread || composeModal(v) == nil { + t.Errorf("a failed send should keep the thread and the form: inThread=%v form=%v", v.inThread, composeModal(v)) } } diff --git a/internal/tui/mail.go b/internal/tui/mail.go index 5a0dd95c..4bf80774 100644 --- a/internal/tui/mail.go +++ b/internal/tui/mail.go @@ -715,6 +715,12 @@ func (v *mailView) Update(msg tea.Msg) (tea.Cmd, bool) { return nil, true } v.modal = nil + // A reply or a forward finishes with the thread it was written from, the way + // the web app sends you back to the box: the reader lands on the list they + // opened the thread from — the Imbox, a search, a bundle — ready for the next. + if v.inThread && form.topicID != 0 && form.topicID == v.topicID { + v.ExitThread() + } return notify(msg.label), true case attachmentSavedMsg: