feat: add tui cluster member overrides

This commit is contained in:
Vincent Koc 2026-04-27 14:36:51 -07:00
parent a35eaee41a
commit 216a5b1728
No known key found for this signature in database
2 changed files with 311 additions and 5 deletions

View File

@ -1024,6 +1024,16 @@ func (m *clusterBrowserModel) openActionMenu() {
}
if member, ok := m.selectedMember(); ok {
sectionAdded := false
if cluster, clusterOK := m.selectedCluster(); clusterOK {
if member.State == "excluded" {
m.menuItems = append(m.menuItems, tuiMenuItem{label: fmt.Sprintf("Include #%d in C%d...", member.Thread.Number, cluster.ID), action: "include-member-confirm"})
} else {
m.menuItems = append(m.menuItems,
tuiMenuItem{label: fmt.Sprintf("Exclude #%d from C%d...", member.Thread.Number, cluster.ID), action: "exclude-member-confirm"},
tuiMenuItem{label: fmt.Sprintf("Set #%d as canonical...", member.Thread.Number), action: "canonical-member-confirm"},
)
}
}
if strings.TrimSpace(member.BodySnippet) != "" {
if !sectionAdded && !menuHasSection(m.menuItems, "Thread") {
m.menuItems = append(m.menuItems, tuiMenuSection("Thread"))
@ -1280,6 +1290,24 @@ func (m *clusterBrowserModel) runMenuItem(item tuiMenuItem) bool {
case "reopen-cluster-local":
m.reopenSelectedClusterLocally()
return true
case "exclude-member-confirm":
m.openExcludeMemberMenu()
return false
case "exclude-member-local":
m.excludeSelectedClusterMemberLocally()
return true
case "include-member-confirm":
m.openIncludeMemberMenu()
return false
case "include-member-local":
m.includeSelectedClusterMemberLocally()
return true
case "canonical-member-confirm":
m.openCanonicalMemberMenu()
return false
case "canonical-member-local":
m.setSelectedClusterCanonicalLocally()
return true
case "load-neighbors":
m.loadSelectedThreadNeighbors(10, 0.2)
return true
@ -1693,6 +1721,57 @@ func (m *clusterBrowserModel) openReopenClusterMenu() {
m.status = fmt.Sprintf("Confirm local reopen for cluster C%d", cluster.ID)
}
func (m *clusterBrowserModel) openExcludeMemberMenu() {
cluster, clusterOK := m.selectedCluster()
member, memberOK := m.selectedMember()
if !clusterOK || !memberOK {
m.status = "No selected cluster member"
return
}
m.menuTitle = "Exclude Member"
m.menuItems = []tuiMenuItem{
{label: fmt.Sprintf("Exclude #%d from C%d", member.Thread.Number, cluster.ID), action: "exclude-member-local"},
{label: "Back to actions", action: "back-to-actions"},
}
m.menuIndex = 0
m.menuOff = 0
m.status = fmt.Sprintf("Confirm local exclude for #%d", member.Thread.Number)
}
func (m *clusterBrowserModel) openIncludeMemberMenu() {
cluster, clusterOK := m.selectedCluster()
member, memberOK := m.selectedMember()
if !clusterOK || !memberOK {
m.status = "No selected cluster member"
return
}
m.menuTitle = "Include Member"
m.menuItems = []tuiMenuItem{
{label: fmt.Sprintf("Include #%d in C%d", member.Thread.Number, cluster.ID), action: "include-member-local"},
{label: "Back to actions", action: "back-to-actions"},
}
m.menuIndex = 0
m.menuOff = 0
m.status = fmt.Sprintf("Confirm local include for #%d", member.Thread.Number)
}
func (m *clusterBrowserModel) openCanonicalMemberMenu() {
cluster, clusterOK := m.selectedCluster()
member, memberOK := m.selectedMember()
if !clusterOK || !memberOK {
m.status = "No selected cluster member"
return
}
m.menuTitle = "Canonical Member"
m.menuItems = []tuiMenuItem{
{label: fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID), action: "canonical-member-local"},
{label: "Back to actions", action: "back-to-actions"},
}
m.menuIndex = 0
m.menuOff = 0
m.status = fmt.Sprintf("Confirm canonical member #%d", member.Thread.Number)
}
func (m *clusterBrowserModel) closeSelectedThreadLocally() {
thread, ok := m.selectedThread()
if !ok {
@ -1766,6 +1845,64 @@ func (m *clusterBrowserModel) reopenSelectedClusterLocally() {
m.status = fmt.Sprintf("Reopened cluster C%d locally", cluster.ID)
}
func (m *clusterBrowserModel) excludeSelectedClusterMemberLocally() {
cluster, clusterOK := m.selectedCluster()
member, memberOK := m.selectedMember()
if !clusterOK || !memberOK {
m.status = "No selected cluster member"
return
}
if m.store == nil || m.repoID == 0 {
m.status = "Local member exclude unavailable for this view"
return
}
if _, err := m.store.ExcludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual exclude"); err != nil {
m.status = err.Error()
return
}
delete(m.neighborCache, member.Thread.ID)
m.refreshFromStore()
m.status = fmt.Sprintf("Excluded #%d from C%d locally", member.Thread.Number, cluster.ID)
}
func (m *clusterBrowserModel) includeSelectedClusterMemberLocally() {
cluster, clusterOK := m.selectedCluster()
member, memberOK := m.selectedMember()
if !clusterOK || !memberOK {
m.status = "No selected cluster member"
return
}
if m.store == nil || m.repoID == 0 {
m.status = "Local member include unavailable for this view"
return
}
if _, err := m.store.IncludeClusterMemberLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual include"); err != nil {
m.status = err.Error()
return
}
m.refreshFromStore()
m.status = fmt.Sprintf("Included #%d in C%d locally", member.Thread.Number, cluster.ID)
}
func (m *clusterBrowserModel) setSelectedClusterCanonicalLocally() {
cluster, clusterOK := m.selectedCluster()
member, memberOK := m.selectedMember()
if !clusterOK || !memberOK {
m.status = "No selected cluster member"
return
}
if m.store == nil || m.repoID == 0 {
m.status = "Local canonical unavailable for this view"
return
}
if _, err := m.store.SetClusterCanonicalLocally(m.ctx, m.repoID, cluster.ID, member.Thread.Number, "TUI manual canonical"); err != nil {
m.status = err.Error()
return
}
m.refreshFromStore()
m.status = fmt.Sprintf("Set #%d as canonical for C%d", member.Thread.Number, cluster.ID)
}
func (m clusterBrowserModel) menuVisibleCount() int {
height := m.detailView.Height
if height <= 0 {
@ -2108,7 +2245,7 @@ func (m clusterBrowserModel) memberTableRows() []table.Row {
thread := member.thread()
rows = append(rows, table.Row{
fmt.Sprintf("#%d", thread.Number),
stateGlyph(threadDisplayState(thread)),
stateGlyph(memberDisplayState(member.member)),
formatRelativeTime(thread.UpdatedAtGitHub),
thread.Title,
})
@ -2579,7 +2716,7 @@ func (m *clusterBrowserModel) sortMembers() {
}
members := make([]store.ClusterMemberDetail, 0, len(m.detail.Members))
for _, member := range m.detail.Members {
if !threadVisible(member.Thread, m.showClosed) {
if !memberVisible(member, m.showClosed) {
continue
}
members = append(members, member)
@ -2842,7 +2979,7 @@ func (m clusterBrowserModel) threadDetailClipboardText() string {
thread := member.Thread
lines := []string{
fmt.Sprintf("%s #%d: %s", kindTitle(thread.Kind), thread.Number, thread.Title),
"State: " + threadDisplayState(thread),
"State: " + memberDisplayState(member),
"Author: " + firstNonEmpty(thread.AuthorLogin, "unknown"),
"Updated: " + firstNonEmpty(thread.UpdatedAtGitHub, thread.UpdatedAt, "unknown"),
"URL: " + thread.HTMLURL,
@ -2958,7 +3095,7 @@ func (m clusterBrowserModel) memberListClipboardText() string {
thread := row.thread()
lines = append(lines, fmt.Sprintf("#%d [%s] %s %s %s",
thread.Number,
threadDisplayState(thread),
memberDisplayState(row.member),
kindTitle(thread.Kind),
thread.Title,
thread.HTMLURL,
@ -2969,7 +3106,7 @@ func (m clusterBrowserModel) memberListClipboardText() string {
func (r memberRow) format(width int) string {
thread := r.thread()
return truncateCells(fmt.Sprintf("#%-7d %-7s %-8s %s", thread.Number, threadDisplayState(thread), formatRelativeTime(thread.UpdatedAtGitHub), thread.Title), width)
return truncateCells(fmt.Sprintf("#%-7d %-7s %-8s %s", thread.Number, memberDisplayState(r.member), formatRelativeTime(thread.UpdatedAtGitHub), thread.Title), width)
}
func (r memberRow) thread() store.Thread {
@ -3257,6 +3394,8 @@ func stateGlyph(state string) string {
return "opn"
case "closed":
return "cls"
case "excluded":
return "exc"
case "local":
return "loc"
case "merged":
@ -3280,6 +3419,20 @@ func threadVisible(thread store.Thread, showClosed bool) bool {
return thread.State == "open" && thread.ClosedAtLocal == ""
}
func memberDisplayState(member store.ClusterMemberDetail) string {
if member.State != "" && member.State != "active" {
return member.State
}
return threadDisplayState(member.Thread)
}
func memberVisible(member store.ClusterMemberDetail, showClosed bool) bool {
if showClosed {
return true
}
return (member.State == "" || member.State == "active") && threadVisible(member.Thread, false)
}
func closedLabel(thread store.Thread) string {
if thread.ClosedAtLocal == "" && thread.State == "open" {
return "no"

View File

@ -1518,6 +1518,94 @@ func TestTUIReopenClusterLocallyRestoresCluster(t *testing.T) {
}
}
func TestTUIClusterMemberOverrideActions(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
if err != nil {
t.Fatalf("open store: %v", err)
}
defer st.Close()
repoID, err := st.UpsertRepository(ctx, store.Repository{Owner: "openclaw", Name: "openclaw", FullName: "openclaw/openclaw", RawJSON: "{}", UpdatedAt: "2026-04-27T00:00:00Z"})
if err != nil {
t.Fatalf("repo: %v", err)
}
firstID, secondID, err := seedTUIClusterPair(ctx, st, repoID, 54, 540, 541)
if err != nil {
t.Fatalf("seed cluster pair: %v", err)
}
clusters, err := st.ListClusterSummaries(ctx, store.ClusterSummaryOptions{RepoID: repoID, IncludeClosed: false, MinSize: 1, Limit: 20, Sort: "recent"})
if err != nil {
t.Fatalf("clusters: %v", err)
}
model := newClusterBrowserModel(ctx, st, repoID, clusterBrowserPayload{
Repository: "openclaw/openclaw",
Sort: "recent",
HideClosed: true,
MinSize: 1,
Clusters: clusters,
})
model.openActionMenu()
if menuLabelIndex(model.menuItems, "Exclude #540 from C54...") < 0 {
t.Fatalf("action menu missing member exclude: %+v", model.menuItems)
}
if menuLabelIndex(model.menuItems, "Set #540 as canonical...") < 0 {
t.Fatalf("action menu missing canonical action: %+v", model.menuItems)
}
model.runAction("exclude-member-confirm")
if model.menuTitle != "Exclude Member" || !strings.Contains(model.menuItems[0].label, "Exclude #540 from C54") {
t.Fatalf("exclude member confirmation menu = %q %+v", model.menuTitle, model.menuItems)
}
model.runAction("exclude-member-local")
if model.status != "Excluded #540 from C54 locally" {
t.Fatalf("exclude status = %q", model.status)
}
if len(model.memberRows) < 2 || model.memberRows[1].thread().Number != 541 {
t.Fatalf("excluded member should be hidden while closed rows are hidden: %#v", model.memberRows)
}
detail, err := st.ClusterDetail(ctx, store.ClusterDetailOptions{RepoID: repoID, ClusterID: 54, IncludeClosed: false, MemberLimit: 10})
if err != nil {
t.Fatalf("detail after exclude: %v", err)
}
if detail.Cluster.RepresentativeThreadID != secondID {
t.Fatalf("representative should refresh after excluding first member: %#v", detail.Cluster)
}
model.showClosed = true
model.refreshFromStore()
model.memberIndex = memberRowIndex(model.memberRows, 540)
model.openActionMenu()
if menuLabelIndex(model.menuItems, "Include #540 in C54...") < 0 {
t.Fatalf("action menu missing member include: %+v", model.menuItems)
}
model.runAction("include-member-confirm")
if model.menuTitle != "Include Member" || !strings.Contains(model.menuItems[0].label, "Include #540 in C54") {
t.Fatalf("include member confirmation menu = %q %+v", model.menuTitle, model.menuItems)
}
model.runAction("include-member-local")
if model.status != "Included #540 in C54 locally" {
t.Fatalf("include status = %q", model.status)
}
model.memberIndex = memberRowIndex(model.memberRows, 541)
model.runAction("canonical-member-confirm")
if model.menuTitle != "Canonical Member" || !strings.Contains(model.menuItems[0].label, "Set #541 as canonical for C54") {
t.Fatalf("canonical confirmation menu = %q %+v", model.menuTitle, model.menuItems)
}
model.runAction("canonical-member-local")
if model.status != "Set #541 as canonical for C54" {
t.Fatalf("canonical status = %q", model.status)
}
detail, err = st.ClusterDetail(ctx, store.ClusterDetailOptions{RepoID: repoID, ClusterID: 54, IncludeClosed: false, MemberLimit: 10})
if err != nil {
t.Fatalf("detail after canonical: %v", err)
}
if detail.Cluster.RepresentativeThreadID != secondID || detail.Members[0].Thread.ID != secondID || detail.Members[0].Role != "canonical" || detail.Members[1].Thread.ID != firstID {
t.Fatalf("canonical member should sort first and become representative: %#v", detail)
}
}
func TestTUIRepositoryPickerKeepsCurrentRepoVisible(t *testing.T) {
ctx := context.Background()
st, err := store.Open(ctx, filepath.Join(t.TempDir(), "gitcrawl.db"))
@ -2062,6 +2150,15 @@ func menuLabelIndex(items []tuiMenuItem, label string) int {
return -1
}
func memberRowIndex(rows []memberRow, number int) int {
for index, row := range rows {
if row.selectable && row.thread().Number == number {
return index
}
}
return -1
}
func seedTUIThreadVector(ctx context.Context, st *store.Store, repoID int64, number int, title string, vector []float64) (int64, error) {
threadID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
@ -2123,3 +2220,59 @@ func seedTUICluster(ctx context.Context, st *store.Store, repoID, clusterID int6
`, clusterID, threadID)
return err
}
func seedTUIClusterPair(ctx context.Context, st *store.Store, repoID, clusterID int64, firstNumber, secondNumber int) (int64, int64, error) {
firstID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: fmt.Sprintf("%d", firstNumber),
Number: firstNumber,
Kind: "issue",
State: "open",
Title: fmt.Sprintf("member %d", firstNumber),
HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", firstNumber),
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: fmt.Sprintf("cluster-pair-hash-%d", firstNumber),
UpdatedAt: "2026-04-27T00:00:00Z",
})
if err != nil {
return 0, 0, err
}
secondID, err := st.UpsertThread(ctx, store.Thread{
RepoID: repoID,
GitHubID: fmt.Sprintf("%d", secondNumber),
Number: secondNumber,
Kind: "issue",
State: "open",
Title: fmt.Sprintf("member %d", secondNumber),
HTMLURL: fmt.Sprintf("https://github.com/openclaw/openclaw/issues/%d", secondNumber),
LabelsJSON: "[]",
AssigneesJSON: "[]",
RawJSON: "{}",
ContentHash: fmt.Sprintf("cluster-pair-hash-%d", secondNumber),
UpdatedAt: "2026-04-27T00:00:00Z",
})
if err != nil {
return 0, 0, err
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_groups(id, repo_id, stable_key, stable_slug, status, representative_thread_id, title, created_at, updated_at)
values(?, ?, ?, ?, 'active', ?, ?, '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z')
`, clusterID, repoID, fmt.Sprintf("cluster-%d", clusterID), fmt.Sprintf("repo-%d", clusterID), firstID, fmt.Sprintf("cluster %d", clusterID)); err != nil {
return 0, 0, err
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at)
values(?, ?, 'representative', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z')
`, clusterID, firstID); err != nil {
return 0, 0, err
}
if _, err := st.DB().ExecContext(ctx, `
insert into cluster_memberships(cluster_id, thread_id, role, state, added_by, added_reason_json, created_at, updated_at)
values(?, ?, 'member', 'active', 'system', '{}', '2026-04-27T00:00:00Z', '2026-04-27T00:00:00Z')
`, clusterID, secondID); err != nil {
return 0, 0, err
}
return firstID, secondID, nil
}