update QoL and ai integration

This commit is contained in:
2026-07-29 14:16:14 +02:00
parent 1d90e364ee
commit 027057e85f
14 changed files with 1893 additions and 84 deletions

View File

@@ -13,6 +13,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/termenv"
)
type recordingService struct {
@@ -1247,11 +1248,12 @@ func TestResolveToggleConfirmsAndUsesCurrentThreadState(t *testing.T) {
func TestPollingMarksNewThreadCommentsUnread(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen = threadScreen
m.screen, m.width, m.height = threadScreen, 80, 8
initial := PRDetails{
PullRequest: PullRequest{ID: "pr", Number: 1},
Threads: []ReviewThread{{
ID: "thread", Path: "a.go", Comments: []ReviewComment{{ID: "comment-1"}},
ID: "thread", Path: "a.go",
Comments: []ReviewComment{{ID: "comment-1", Author: "alice", Body: "Original"}},
}},
}
updated, _ := m.Update(detailsLoadedMsg{number: 1, details: initial})
@@ -1263,22 +1265,137 @@ func TestPollingMarksNewThreadCommentsUnread(t *testing.T) {
refreshed := initial
refreshed.Threads = []ReviewThread{{
ID: "thread", Path: "a.go",
Comments: []ReviewComment{{ID: "comment-1"}, {ID: "comment-2"}},
Comments: []ReviewComment{
{ID: "comment-1", Author: "alice", Body: "Original"},
{ID: "comment-2", Author: "bob", Body: "New reply"},
},
}}
updated, _ = m.Update(detailsLoadedMsg{number: 1, details: refreshed})
m = updated.(App)
if !m.unreadThreads["thread"] {
if !m.unreadThreads["thread"] || !m.unreadComments["comment-2"] ||
m.newThreads["thread"] {
t.Fatal("new review comment was not marked unread")
}
plain := ansi.Strip(m.threadList(48, 10))
if !strings.Contains(plain, "NEW") {
t.Fatalf("thread list does not indicate unread update:\n%s", plain)
}
detail := m.detailLines(60)
dividerFound, emphasizedRail := false, false
for _, line := range detail {
if strings.Contains(ansi.Strip(line.text), "NEW MESSAGES") {
dividerFound = true
}
if line.anchor == "comment:comment-2:header" && ansi.Strip(line.rail) == "┃ " {
emphasizedRail = true
}
}
if !dividerFound || !emphasizedRail {
t.Fatalf("unread detail treatment missing: divider=%t rail=%t", dividerFound, emphasizedRail)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
m = updated.(App)
if !m.unreadThreads["thread"] {
t.Fatal("moving the thread-list cursor marked the thread read")
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("l")})
m = updated.(App)
if !m.unreadThreads["thread"] {
t.Fatal("focusing the thread detail marked the thread read")
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
m = updated.(App)
if !m.unreadThreads["thread"] || m.focus != threadDetailPane ||
m.detailScrollAnchor() != "unread:comment-2" {
t.Fatal("next-unread did not preserve and position the unread update")
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
m = updated.(App)
if m.unreadThreads["thread"] || m.unreadComments["comment-2"] {
t.Fatal("scrolling the visible final unread comment did not mark the thread read")
}
}
func TestUnreadThreadClearsOnlyWhenLastUnreadCommentIsVisible(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.focus, m.listHidden = threadScreen, threadDetailPane, true
m.width, m.height = 60, 8
m.details = PRDetails{
PullRequest: PullRequest{ID: "pr", Number: 1},
Threads: []ReviewThread{{
ID: "thread", Path: "a.go",
Comments: []ReviewComment{
{ID: "old", Author: "alice", Body: "Old"},
{ID: "new-1", Author: "bob", Body: strings.Repeat("First new message ", 8)},
{ID: "new-2", Author: "carol", Body: "Last new message"},
},
}},
}
m.unreadThreads["thread"] = true
m.unreadComments["new-1"] = true
m.unreadComments["new-2"] = true
lines := m.renderedDetailLines(m.width)
firstHeader, lastHeader := -1, -1
for index, line := range lines {
switch line.anchor {
case "comment:new-1:header":
firstHeader = index
case "comment:new-2:header":
lastHeader = index
}
}
if firstHeader < 0 || lastHeader <= firstHeader {
t.Fatalf("unread comment anchors = %d, %d", firstHeader, lastHeader)
}
m.scroll = firstHeader
m.acknowledgeVisibleUnread()
if !m.unreadThreads["thread"] {
t.Fatal("thread was read before the last unread comment became visible")
}
m.scroll = max(0, lastHeader-m.detailViewportHeight()+1)
m.acknowledgeVisibleUnread()
if m.unreadThreads["thread"] {
t.Fatal("visiting unread thread did not mark it read")
t.Fatal("thread remained unread after the last unread comment became visible")
}
}
func TestManualMarkReadIsFallbackForNewThread(t *testing.T) {
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.screen, m.width, m.height = threadScreen, 80, 20
initial := PRDetails{
PullRequest: PullRequest{ID: "pr", Number: 1},
Threads: []ReviewThread{{
ID: "existing", Path: "a.go",
Comments: []ReviewComment{{ID: "existing-comment"}},
}},
}
m.trackThreadUpdates(initial)
updatedDetails := initial
updatedDetails.Threads = append(updatedDetails.Threads, ReviewThread{
ID: "new-thread", Path: "b.go",
Comments: []ReviewComment{{ID: "new-root", Author: "alice", Body: "New thread"}},
})
m.trackThreadUpdates(updatedDetails)
m.details = updatedDetails
m.threadIndex = 1
if !m.newThreads["new-thread"] || !m.unreadComments["new-root"] ||
!strings.Contains(ansi.Strip(m.threadList(60, 10)), "NEW THREAD") {
t.Fatal("new thread did not receive the distinct unread treatment")
}
for _, line := range m.detailLines(60) {
if strings.Contains(ansi.Strip(line.text), "NEW MESSAGES") {
t.Fatal("completely new thread received a partial-update divider")
}
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("m")})
m = updated.(App)
if m.unreadThreads["new-thread"] || m.unreadComments["new-root"] ||
m.newThreads["new-thread"] {
t.Fatal("manual mark-read fallback did not clear the selected thread")
}
}
@@ -1766,6 +1883,38 @@ func TestThreadDetailShowsCommentReactions(t *testing.T) {
}
}
func TestThreadDetailHighlightsContributorMentions(t *testing.T) {
defer applyTheme("dark")
if err := applyTheme("dark"); err != nil {
t.Fatal(err)
}
previousProfile := lipgloss.ColorProfile()
lipgloss.SetColorProfile(termenv.TrueColor)
t.Cleanup(func() { lipgloss.SetColorProfile(previousProfile) })
m := NewApp(nil, "o", "r", false, 50, 10*time.Second)
m.width = 80
m.details = PRDetails{Threads: []ReviewThread{{
ID: "thread", Path: "main.go", Comments: []ReviewComment{{
ID: "comment", Author: "reviewer",
Body: "Could @pablu and @other-contributor check this?",
}},
}}}
var body strings.Builder
for _, line := range m.detailLines(70) {
if strings.Contains(line.anchor, ":body:") {
body.WriteString(line.text)
}
}
for _, login := range []string{"pablu", "other-contributor"} {
mention := "@" + login
if !strings.Contains(body.String(), authorStyle(login).Render(mention)) {
t.Fatalf("thread mention %s is not author-styled: %q", mention, body.String())
}
}
}
func TestReactionSummaryWrapsBetweenBadges(t *testing.T) {
reactions := []ReactionSummary{
{Content: "THUMBS_UP", Count: 10},