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

131
tui.go
View File

@@ -185,6 +185,8 @@ type App struct {
knownComments map[string]bool
initializedPRs map[string]bool
unreadThreads map[string]bool
unreadComments map[string]bool
newThreads map[string]bool
updatedThreads map[string]bool
ai *AIController
aiStore *AIStore
@@ -268,6 +270,7 @@ func NewAppWithSettings(
drafts: settings.Drafts,
knownThreads: make(map[string]bool), knownComments: make(map[string]bool),
initializedPRs: make(map[string]bool), unreadThreads: make(map[string]bool),
unreadComments: make(map[string]bool), newThreads: make(map[string]bool),
updatedThreads: make(map[string]bool),
ai: settings.AI, aiStore: settings.AIStore,
}
@@ -941,7 +944,7 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.details.Threads[index].ID == msg.threadID {
m.details.Threads[index].Comments = append(m.details.Threads[index].Comments, msg.comment)
m.threadIndex = index
m.markCurrentThreadRead()
m.markCommentRead(msg.comment.ID)
break
}
}
@@ -1223,6 +1226,10 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.screen == threadScreen {
m.moveToUnread(-1)
}
case "m":
if m.screen == threadScreen {
m.markCurrentThreadRead()
}
case "d":
if m.screen == prScreen && len(m.prs) > 0 {
return m, m.openSelectedPR(dashboardScreen)
@@ -1245,7 +1252,6 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if m.screen == threadScreen {
m.focus = threadDetailPane
m.markCurrentThreadRead()
}
case "h":
if m.screen == threadScreen {
@@ -1263,7 +1269,6 @@ func (m App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.screen == threadScreen && len(m.details.Threads) > 0 {
thread := m.details.Threads[m.threadIndex]
m.folded[thread.ID] = !m.folded[thread.ID]
m.markCurrentThreadRead()
m.scroll = 0
}
case "b", "esc":
@@ -1386,6 +1391,8 @@ func (m *App) trackThreadUpdates(details PRDetails) {
m.knownComments = make(map[string]bool)
m.initializedPRs = make(map[string]bool)
m.unreadThreads = make(map[string]bool)
m.unreadComments = make(map[string]bool)
m.newThreads = make(map[string]bool)
}
m.updatedThreads = make(map[string]bool)
prID := details.ID
@@ -1412,10 +1419,15 @@ func (m *App) trackThreadUpdates(details PRDetails) {
return
}
for _, thread := range details.Threads {
updated := !state.Threads[thread.ID]
threadIsNew := !state.Threads[thread.ID]
updated := threadIsNew
if threadIsNew {
m.newThreads[thread.ID] = true
}
for _, comment := range thread.Comments {
if !state.Comments[comment.ID] {
updated = true
m.unreadComments[comment.ID] = true
}
m.knownComments[comment.ID] = true
}
@@ -1432,16 +1444,20 @@ func (m *App) markCurrentThreadRead() {
if m.threadIndex >= 0 && m.threadIndex < len(m.details.Threads) {
thread := m.details.Threads[m.threadIndex]
delete(m.unreadThreads, thread.ID)
delete(m.newThreads, thread.ID)
prID := m.currentPRKey()
state := m.readState.Data[prID]
if state.Threads == nil {
state.Threads = make(map[string]bool)
}
if state.Comments == nil {
state.Comments = make(map[string]bool)
}
state.Initialized = true
state.Threads[thread.ID] = true
for _, comment := range thread.Comments {
state.Comments[comment.ID] = true
delete(m.unreadComments, comment.ID)
}
m.readState.Data[prID] = state
if err := m.readState.save(); err != nil {
@@ -1450,6 +1466,23 @@ func (m *App) markCurrentThreadRead() {
}
}
func (m *App) markCommentRead(commentID string) {
if commentID == "" {
return
}
delete(m.unreadComments, commentID)
prID := m.currentPRKey()
state := m.readState.Data[prID]
if state.Comments == nil {
state.Comments = make(map[string]bool)
}
state.Comments[commentID] = true
m.readState.Data[prID] = state
if err := m.readState.save(); err != nil {
m.recordHealth("read state", healthWarning, err.Error())
}
}
func (m App) currentPRKey() string {
if m.details.ID != "" {
return m.details.ID
@@ -1457,6 +1490,60 @@ func (m App) currentPRKey() string {
return fmt.Sprintf("%s#%d", m.details.RepoWithOwner, m.details.Number)
}
func (m *App) scrollToFirstUnread() {
if m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) {
return
}
thread := m.details.Threads[m.threadIndex]
firstUnread := ""
for _, comment := range thread.Comments {
if m.unreadComments[comment.ID] {
firstUnread = comment.ID
break
}
}
if firstUnread == "" {
return
}
width, _ := m.detailPaneSize()
dividerAnchor := "unread:" + firstUnread
commentAnchor := "comment:" + firstUnread + ":header"
for index, line := range m.renderedDetailLines(width) {
if line.anchor == dividerAnchor || line.anchor == commentAnchor {
m.scroll = min(index, m.detailMaxScroll())
return
}
}
}
func (m *App) acknowledgeVisibleUnread() {
if m.screen != threadScreen || m.focus != threadDetailPane ||
m.threadIndex < 0 || m.threadIndex >= len(m.details.Threads) {
return
}
thread := m.details.Threads[m.threadIndex]
lastUnread := ""
for _, comment := range thread.Comments {
if m.unreadComments[comment.ID] {
lastUnread = comment.ID
}
}
if lastUnread == "" {
return
}
width, _ := m.detailPaneSize()
lines := m.renderedDetailLines(width)
viewportHeight := m.detailViewportHeight()
scroll := min(m.scroll, max(0, len(lines)-viewportHeight))
lastAnchor := "comment:" + lastUnread + ":header"
for index, line := range lines {
if line.anchor == lastAnchor && index >= scroll && index < scroll+viewportHeight {
m.markCurrentThreadRead()
return
}
}
}
func (m *App) moveToUnread(direction int) {
count := len(m.details.Threads)
if count == 0 {
@@ -1469,7 +1556,8 @@ func (m *App) moveToUnread(direction int) {
}
if m.unreadThreads[m.details.Threads[index].ID] {
m.threadIndex, m.scroll = index, 0
m.markCurrentThreadRead()
m.focus = threadDetailPane
m.scrollToFirstUnread()
return
}
}
@@ -1491,12 +1579,10 @@ func (m *App) move(delta int) {
}
m.threadIndex = matches[clamp(position+delta, 0, len(matches)-1)]
m.scroll = 0
m.markCurrentThreadRead()
return
}
m.threadIndex = clamp(m.threadIndex+delta, 0, len(m.details.Threads)-1)
m.scroll = 0
m.markCurrentThreadRead()
}
func (m *App) moveSearch(delta int) {
@@ -1695,6 +1781,7 @@ func (m *App) toStart() {
m.scroll = 0
} else if m.focus == threadDetailPane {
m.scroll = 0
m.acknowledgeVisibleUnread()
} else {
m.threadIndex, m.scroll = 0, 0
}
@@ -1708,6 +1795,7 @@ func (m *App) toEnd() {
m.healthScroll = m.healthMaxScroll()
} else if m.focus == threadDetailPane {
m.scroll = m.detailMaxScroll()
m.acknowledgeVisibleUnread()
} else {
m.threadIndex, m.scroll = max(0, len(m.details.Threads)-1), 0
}
@@ -1734,6 +1822,7 @@ func (m *App) page(direction int) {
func (m *App) scrollDetail(delta int) {
m.scroll = clamp(m.scroll+delta, 0, m.detailMaxScroll())
m.acknowledgeVisibleUnread()
}
func (m *App) scrollDashboard(delta int) {
@@ -2088,6 +2177,7 @@ func (m App) helpBindings() []helpBinding {
{keyLabel(m.keybindings.Threads.Search), "Fuzzy-search file paths and combine status:, author:, and updated:true filters"},
{keyLabel(m.keybindings.Threads.ClearFilter), "Clear the active thread filter and show every thread"},
{combinedKeyLabel(m.keybindings.Threads.NextUnread, m.keybindings.Threads.PreviousUnread), "Next / previous new update"},
{keyLabel(m.keybindings.Threads.MarkRead), "Mark the selected thread read"},
{keyLabel(m.keybindings.Threads.Reply), "Compose a reply to the selected thread"},
{keyLabel(m.keybindings.Threads.Resolve), "Resolve or unresolve the selected thread"},
{keyLabel(m.keybindings.Views.AI), "Open the local AI review or selected-thread discussion menu"},
@@ -3120,7 +3210,11 @@ func (m App) threadList(width, height int) string {
}
suffix := fmt.Sprintf(":%d · %d", thread.Line, len(thread.Comments))
if m.unreadThreads[thread.ID] {
suffix += " " + warnStyle.Render("NEW")
label := "NEW"
if m.newThreads[thread.ID] {
label = "NEW THREAD"
}
suffix += " " + warnStyle.Render(label)
}
pathWidth := max(4, innerWidth-lipgloss.Width(suffix)-2)
path := truncatePath(thread.Path, pathWidth)
@@ -3194,7 +3288,11 @@ func (m App) detailLines(width int) []detailLine {
status += ", LOCAL AI · LOCAL ONLY"
}
if m.unreadThreads[thread.ID] {
status += ", new updates"
if m.newThreads[thread.ID] {
status += ", new thread"
} else {
status += ", new updates"
}
}
if len(thread.Comments) > 0 && thread.Comments[0].OriginalCommitOID != "" {
status += ", snapshot " + shortOID(thread.Comments[0].OriginalCommitOID)
@@ -3229,9 +3327,24 @@ func (m App) detailLines(width int) []detailLine {
lines = append(lines, wrapped...)
}
}
unreadDivider := false
for _, comment := range thread.Comments {
commentUnread := m.unreadComments[comment.ID]
if commentUnread && !m.newThreads[thread.ID] && !unreadDivider {
lines = append(lines,
detailLine{},
detailLine{
anchor: "unread:" + comment.ID,
text: warnStyle.Render("── NEW MESSAGES ──"),
},
)
unreadDivider = true
}
content := parseCommentBody(comment.Body)
rail := lipgloss.NewStyle().Foreground(authorColor(comment.Author)).Render("│ ")
if commentUnread && !m.newThreads[thread.ID] {
rail = warnStyle.Render("┃ ")
}
lines = append(lines, detailLine{}, detailLine{
rail: rail,
anchor: "comment:" + comment.ID + ":header",