From 1b35456ae619d7fe15eb0acad7bac1d2af7702e1 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 19 Jul 2026 21:29:12 +0300 Subject: [PATCH] refine(web/beads): concise label events, drop standalone close reason, wrap long lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Label audit events collapse to one line ("added label milestone:m3") instead of a "label added" header plus a redundant "Added label: …" body. The label name is taken after the first colon so namespaced labels survive. - Remove the standalone Close reason block below the activity: the reason already shows as the History tab's `closed` event, so it was a duplicate. - Add overflow-wrap: anywhere to the timeline body, comment body, and field-body pre so a very long single-line reason wraps instead of overflowing. --- web/beads.go | 48 ++++++++++++++++++++++++++++------------ web/beads_test.go | 46 +++++++++++++++++++++++++++++--------- web/templates/beads.html | 13 ++++------- 3 files changed, 74 insertions(+), 33 deletions(-) diff --git a/web/beads.go b/web/beads.go index e4e8289632869bfe191f97eaf6321345cd8834a1..7565d30c84d840ce9d338ac73890926e111827db 100644 --- a/web/beads.go +++ b/web/beads.go @@ -503,15 +503,8 @@ func (v *beadsView) buildDetail( continue } et := cell(ecols, r, "event_type") - summary, text := humanizeEvent(et, cell(ecols, r, "old_value"), cell(ecols, r, "new_value")) - if note := cell(ecols, r, "comment"); note != "" { - // An event may carry its own free-text note alongside the change. - if text != "" { - text += "\n" + note - } else { - text = note - } - } + summary, text := humanizeEvent(et, + cell(ecols, r, "old_value"), cell(ecols, r, "new_value"), cell(ecols, r, "comment")) data.History = append(data.History, BeadActivity{ Kind: "event", Event: et, @@ -689,14 +682,21 @@ func sortActivity(acts []BeadActivity) { // humanizeEvent turns one audit row into a readable summary line (and optional // body text). status_changed / updated carry a JSON new_value fragment // ({"status":"in_progress"}, {"priority":0}); created and closed are lifecycle -// markers, with closed's new_value holding the free-text close reason. -func humanizeEvent(eventType, oldVal, newVal string) (summary, text string) { +// markers, with closed's new_value holding the free-text close reason; label +// events keep their whole story in the comment note, so they collapse to a +// single summary line rather than a "label added" header + redundant body. +func humanizeEvent(eventType, oldVal, newVal, note string) (summary, text string) { + note = strings.TrimSpace(note) switch strings.ToLower(strings.TrimSpace(eventType)) { case "created": return "created the issue", "" case "closed": - // new_value is the close reason (plain text), not JSON. - return "closed the issue", strings.TrimSpace(newVal) + // new_value is the close reason (plain text), not JSON; older rows put it + // in the note instead. + if r := strings.TrimSpace(newVal); r != "" { + return "closed the issue", r + } + return "closed the issue", note case "status_changed": if s := jsonField(newVal, "status"); s != "" { return "changed status to " + s, "" @@ -707,13 +707,33 @@ func humanizeEvent(eventType, oldVal, newVal string) (summary, text string) { return "updated " + pairs, "" } return "updated the issue", "" + case "label_added": + return labelLine(note, "added"), "" + case "label_removed": + return labelLine(note, "removed"), "" default: et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ") if et == "" { et = "changed" } - return et, "" + return et, note + } +} + +// labelLine collapses a label event to one line. The note reads "Added label: +// "; we drop everything up to the FIRST colon (the "Added label:" prefix) +// and keep the rest, so a namespaced label like "milestone:m3" survives intact +// and the summary becomes "added label milestone:m3". +func labelLine(note, verb string) string { + name := note + if i := strings.Index(name, ":"); i >= 0 { + name = name[i+1:] + } + name = strings.TrimSpace(name) + if name == "" { + return verb + " a label" } + return verb + " label " + name } // jsonField extracts one string-ish field from a JSON object fragment, or "" diff --git a/web/beads_test.go b/web/beads_test.go index d5fb3a6f614e101809f6c3083a37e6d07a6e26a9..76f27f7f6a8eb0733f5d27a0ce6b771d4d7491d5 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -81,6 +81,15 @@ func beadsFixture() *fakeSession { }, Total: 2, } + // i-done's closure is recorded as a `closed` audit event carrying the reason + // (the only place the reason now surfaces — there is no standalone block). + events := &browse.RowPage{ + Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"}, + Rows: [][]string{ + {"e1", "i-done", "closed", "carol", "NULL", "Fixed in commit abc123", "NULL", "2024-01-03 12:00:00"}, + }, + Total: 1, + } return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, tables: beadsTables(), @@ -90,6 +99,7 @@ func beadsFixture() *fakeSession { "labels": labels, "custom_statuses": statuses, "comments": comments, + "events": events, }, } } @@ -335,6 +345,8 @@ func TestBeadsHandleViewDetail(t *testing.T) { // A closed issue's detail pane must surface its close reason (and the closed // timestamp), so the resolution recorded by `bd close -r` is not lost. +// TestBeadsDetailShowsCloseReason: the close reason surfaces through the History +// tab's `closed` event, not a standalone block (which was removed). func TestBeadsDetailShowsCloseReason(t *testing.T) { h := newHarness(t) h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic}) @@ -346,11 +358,15 @@ func TestBeadsDetailShowsCloseReason(t *testing.T) { t.Fatalf("detail: got %d, want 200; body=%s", rec.Code, rec.Body.String()) } body := rec.Body.String() - for _, want := range []string{"Close reason", "Fixed in commit abc123", "2024-01-04"} { + for _, want := range []string{"closed the issue", "Fixed in commit abc123"} { if !strings.Contains(body, want) { - t.Errorf("closed-issue detail missing %q; body=%s", want, body) + t.Errorf("closed-issue history missing %q; body=%s", want, body) } } + // The old standalone block is gone; the reason lives only in the timeline. + if strings.Contains(body, "

Close reason

") { + t.Errorf("standalone Close reason block should be removed; body=%s", body) + } } // --- epic mode + history ----------------------------------------------------- @@ -400,9 +416,10 @@ func beadsEpicFixture() *fakeSession { {"e1", "i-epic", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-01 08:00:00"}, {"e2", "i-epic", "status_changed", "Eugene", `{"status":"open"}`, `{"status":"in_progress"}`, "NULL", "2024-01-02 10:00:00"}, {"e3", "i-epic", "updated", "Eugene", "NULL", `{"priority":0}`, "NULL", "2024-01-03 11:00:00"}, + {"e4", "i-epic", "label_added", "Eugene", "NULL", "NULL", "Added label: milestone:m3", "2024-01-04 09:00:00"}, {"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"}, }, - Total: 4, + Total: 5, } return &fakeSession{ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}}, @@ -459,9 +476,9 @@ func TestBeadsHistoryMerge(t *testing.T) { if len(d.Comments) != 1 || d.Comments[0].Text != "kickoff" { t.Fatalf("comments = %+v, want just the epic's kickoff", d.Comments) } - // History merges the epic's 1 comment + 3 events (i-c1's are excluded), time-sorted. - if len(d.History) != 4 { - t.Fatalf("history len = %d, want 4: %+v", len(d.History), d.History) + // History merges the epic's 1 comment + 4 events (i-c1's are excluded), time-sorted. + if len(d.History) != 5 { + t.Fatalf("history len = %d, want 5: %+v", len(d.History), d.History) } for i := 1; i < len(d.History); i++ { if d.History[i-1].CreatedAt > d.History[i].CreatedAt { @@ -475,18 +492,27 @@ func TestBeadsHistoryMerge(t *testing.T) { if last.Kind != "comment" || last.Text != "kickoff" { t.Errorf("last history = %+v, want the kickoff comment", last) } - // Humanized change lines. - var sawStatus, sawUpdate bool + // Humanized change lines, and a label event collapsed to one line with no + // redundant "Added label:" body. + var sawStatus, sawUpdate, sawLabel bool for _, a := range d.History { switch a.Summary { case "changed status to in_progress": sawStatus = true case "updated priority to 0": sawUpdate = true + case "added label milestone:m3": + sawLabel = true + if a.Text != "" { + t.Errorf("label event should have no body, got %q", a.Text) + } + } + if strings.Contains(a.Text, "Added label:") { + t.Errorf("label note leaked into a history body: %+v", a) } } - if !sawStatus || !sawUpdate { - t.Errorf("history missing humanized change lines; status=%v update=%v", sawStatus, sawUpdate) + if !sawStatus || !sawUpdate || !sawLabel { + t.Errorf("history missing humanized lines; status=%v update=%v label=%v", sawStatus, sawUpdate, sawLabel) } } diff --git a/web/templates/beads.html b/web/templates/beads.html index 478bf64adce9a153f7a8f106c46234fac76887e5..a096d851a3802c4ce9b7e1f4031ca31d69939f0c 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -83,8 +83,8 @@ .bead-detail .field-label { color: var(--bd-muted); font-weight: 600; font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; } .bead-detail table td.field-label { white-space: nowrap; width: 9rem; vertical-align: middle; } .bead-detail pre.field-body { - white-space: pre-wrap; background: var(--bd-panel); border: 1px solid var(--bd-border); - color: var(--bd-fg); padding: .5rem; margin: .25rem 0 1rem; + white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bd-panel); + border: 1px solid var(--bd-border); color: var(--bd-fg); padding: .5rem; margin: .25rem 0 1rem; } .bead-deplist { list-style: none; padding-left: 0; margin-bottom: 0; } .bead-deplist li { padding: .2rem 0; border-top: 1px solid var(--bd-border); } @@ -92,7 +92,7 @@ .bead-deplist code { font-size: .8rem; } .bead-comment { border: 1px solid var(--bd-border); border-left: 2px solid var(--lane-linedup); padding: .4rem .6rem; margin-bottom: .5rem; } .bead-comment .c-head { font-size: .8rem; color: var(--bd-muted); margin-bottom: .2rem; } -.bead-comment .c-body { white-space: pre-wrap; color: var(--bd-fg); } +.bead-comment .c-body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--bd-fg); } /* epic: badge, subtask rollup, progress meter */ .epic-badge { border-color: var(--lane-stalled); color: var(--bd-fg); text-transform: uppercase; letter-spacing: .04em; } @@ -131,7 +131,7 @@ .bead-timeline .tl-comment::before { background: var(--lane-linedup); } .bead-timeline .tl-head { font-size: .85rem; color: var(--bd-fg); } .bead-timeline .tl-when { color: var(--bd-muted); } -.bead-timeline .tl-body { white-space: pre-wrap; color: var(--bd-fg); margin-top: .15rem; padding-left: .1rem; } +.bead-timeline .tl-body { white-space: pre-wrap; overflow-wrap: anywhere; color: var(--bd-fg); margin-top: .15rem; padding-left: .1rem; }
@@ -264,11 +264,6 @@ {{else}}

No activity yet.

{{end}}
- -{{if .CloseReason}} -

Close reason

-
{{.CloseReason}}
-{{end}} {{else}}
Issue not found.
{{end}}