~bigbes/sr-ht-dolt

2ee66fb89c83da809c295f2b8bb52f630e55f629 — Eugene Blikh 29 days ago 419fb13
feat(web/beads): epic view with subtask rollup, tabbed Comments/History

Three additions to the beads issue detail:

- Epic mode: when the viewed issue is issue_type=epic, render a Subtasks panel
  — its parent-child children (deps pointing at the epic) with a done/total
  progress meter, sorted open-work-first with closed sinking to the bottom.

- Activity is now a two-tab strip (pure-CSS, no JS): Comments keeps the plain
  thread; History merges comments and the audit-log events table into one
  time-sorted timeline with humanized change lines ("changed status to
  in_progress", "updated priority to 0", "closed the issue" + reason).

- Close reason moved from the top metadata table to its own block at the very
  bottom, after the activity — it reads as a closing note, not a header field.

Verified end-to-end against a real 143-issue beads store (tarantool-etcd) via
the env-guarded TestRealBeadsStore smoke test; unit-covered with a dedicated
epic/history fixture.
4 files changed, 604 insertions(+), 24 deletions(-)

M web/beads.go
M web/beads_test.go
A web/realdata_test.go
M web/templates/beads.html
M web/beads.go => web/beads.go +263 -14
@@ 2,7 2,9 @@ package web

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"sort"
	"strconv"


@@ 56,9 58,10 @@ func (*beadsView) Applies(tables []browse.TableInfo) bool {
// --- view model --------------------------------------------------------------

// BeadsData is the opaque .Data value handed to beads.html. Mode discriminates
// the two renderings: "board" (all lanes) or "detail" (one issue).
// the renderings: "board" (all lanes), "detail" (one issue), or "epic" (a
// detail whose issue is an epic, which also carries its subtask rollup).
type BeadsData struct {
	Mode string // "board" | "detail"
	Mode string // "board" | "detail" | "epic"

	// board mode
	Lanes     []BeadsLane


@@ 67,11 70,17 @@ type BeadsData struct {
	Truncated bool // an input table exceeded beadsMax and was clipped
	ShownOf   int  // when Truncated: the reported table total

	// detail mode
	// detail / epic modes
	Issue        *BeadIssue
	DependsOn    []BeadEdge // this issue depends on … (outgoing)
	DependedOnBy []BeadEdge // … is depended on by this issue (incoming)
	Comments     []BeadComment
	DependsOn    []BeadEdge     // this issue depends on … (outgoing)
	DependedOnBy []BeadEdge     // … is depended on by this issue (incoming)
	Comments     []BeadComment  // the comment thread (Comments tab)
	History      []BeadActivity // comments + audit events, time-sorted (History tab)

	// epic mode: the issue's parent-child children and their rollup.
	Subtasks     []BeadSubtask
	SubtaskDone  int // # of subtasks in the closed category
	SubtaskTotal int // len(Subtasks); the progress denominator
}

// BeadsLane is one parade lane and the cards in it.


@@ 131,6 140,53 @@ type BeadComment struct {
	CreatedAt string
}

// BeadActivity is one entry in the merged history timeline: either a comment or
// an audit event from the events table. Summary is a human-readable one-liner
// ("changed status to in_progress"); Text carries the comment body or an event's
// free-text note. Kind drives the icon/label in the template.
type BeadActivity struct {
	Kind      string // "comment" | "event"
	Event     string // events only: the event_type (created/status_changed/updated/closed/…)
	Actor     string
	Summary   string
	Text      string
	CreatedAt string
}

// BeadSubtask is one child of an epic — the "from" side of a parent-child
// dependency that points at the epic. Category (open/in_progress/closed) drives
// the status accent and feeds the epic's progress rollup.
type BeadSubtask struct {
	ID       string
	Title    string
	Status   string
	Category string
	Priority string
	Assignee string
	Blocked  bool
}

// PriorityLabel renders a subtask's numeric priority as a P-pill ("P0".."P3"),
// or "" when unset/unparseable.
func (s BeadSubtask) PriorityLabel() string {
	if s.Priority == "" {
		return ""
	}
	if _, err := strconv.Atoi(s.Priority); err != nil {
		return ""
	}
	return "P" + s.Priority
}

// SubtaskPct is the epic's completion percentage (0..100), for the progress bar
// width. Zero subtasks reads as 0%.
func (d *BeadsData) SubtaskPct() int {
	if d.SubtaskTotal == 0 {
		return 0
	}
	return d.SubtaskDone * 100 / d.SubtaskTotal
}

// BeadIssue is the full issue shown in the detail pane. The field set mirrors
// the user-facing columns bd surfaces for an issue (see `bd show`): identity and
// status, the four long-text bodies, effort/reference metadata, the full


@@ 306,8 362,10 @@ func (v *beadsView) Build(ctx context.Context, sess BrowseSession, _ *core.Repo,
}

// buildDetail assembles the single-issue view: the issue's own fields, its
// dependency edges in both directions (target title/status resolved), and its
// comments thread.
// dependency edges in both directions (target title/status resolved), its
// comments thread, and a merged history timeline. When the issue is an epic
// (issue_type == "epic") it switches to Mode "epic" and also gathers the
// parent-child children as a subtask rollup.
func (v *beadsView) buildDetail(
	ctx context.Context, sess BrowseSession, ref, want string,
	issues *browse.RowPage, issueCols map[string]int,


@@ 315,14 373,16 @@ func (v *beadsView) buildDetail(
	labelsByIssue map[string][]string,
	catByStatus, catByIssue map[string]string,
) *BeadsData {
	// id → (title, status) for edge labels.
	// id → (title, status, whole row) for edge labels and the subtask rollup.
	titleByIssue := map[string]string{}
	statusByIssue := map[string]string{}
	rowByID := make(map[string][]string, len(issues.Rows))
	var row []string
	for _, r := range issues.Rows {
		id := cell(issueCols, r, "id")
		titleByIssue[id] = cell(issueCols, r, "title")
		statusByIssue[id] = cell(issueCols, r, "status")
		rowByID[id] = r
		if id == want {
			row = r
		}


@@ 335,6 395,12 @@ func (v *beadsView) buildDetail(
		return data
	}

	// An epic gets its own rendering mode; the template branches on it to add the
	// subtask rollup while reusing the shared detail chrome.
	if strings.EqualFold(cell(issueCols, row, "issue_type"), "epic") {
		data.Mode = "epic"
	}

	status := cell(issueCols, row, "status")
	name, accent := laneForCategory(statusCategory(status, catByStatus))
	data.Issue = &BeadIssue{


@@ 382,24 448,82 @@ func (v *beadsView) buildDetail(
		}
		if to == want && from != "" {
			data.DependedOnBy = append(data.DependedOnBy, edge(from, typ))
			// A parent-child edge pointing at this issue makes `from` a subtask,
			// but that only matters when this issue is an epic.
			if data.Mode == "epic" && strings.EqualFold(typ, "parent-child") {
				cr := rowByID[from]
				cat := catByIssue[from]
				st := BeadSubtask{
					ID:       from,
					Title:    titleByIssue[from],
					Status:   statusByIssue[from],
					Category: cat,
					Priority: cell(issueCols, cr, "priority"),
					Assignee: cell(issueCols, cr, "assignee"),
					Blocked:  truthy(cell(issueCols, cr, "is_blocked")),
				}
				data.Subtasks = append(data.Subtasks, st)
				data.SubtaskTotal++
				if cat == "closed" {
					data.SubtaskDone++
				}
			}
		}
	}
	sortSubtasks(data.Subtasks)

	// Comments are optional; a missing table just yields an empty thread.
	// Comments are optional; a missing table just yields an empty thread. Each
	// comment is also folded into the merged history timeline below.
	if comments, _, err := readRowsOptional(ctx, sess, ref, "comments"); err == nil && comments != nil {
		ccols := indexCols(comments.Columns)
		for _, r := range comments.Rows {
			if cell(ccols, r, "issue_id") != want {
				continue
			}
			data.Comments = append(data.Comments, BeadComment{
				Author:    cell(ccols, r, "author"),
				Text:      cell(ccols, r, "text"),
				CreatedAt: cell(ccols, r, "created_at"),
			author := cell(ccols, r, "author")
			text := cell(ccols, r, "text")
			at := cell(ccols, r, "created_at")
			data.Comments = append(data.Comments, BeadComment{Author: author, Text: text, CreatedAt: at})
			data.History = append(data.History, BeadActivity{
				Kind:      "comment",
				Actor:     author,
				Summary:   "commented",
				Text:      text,
				CreatedAt: at,
			})
		}
	}

	// The audit log (events) is optional too; when present it joins the comments
	// in the History tab as humanized, time-ordered entries.
	if events, _, err := readRowsOptional(ctx, sess, ref, "events"); err == nil && events != nil {
		ecols := indexCols(events.Columns)
		for _, r := range events.Rows {
			if cell(ecols, r, "issue_id") != want {
				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
				}
			}
			data.History = append(data.History, BeadActivity{
				Kind:      "event",
				Event:     et,
				Actor:     cell(ecols, r, "actor"),
				Summary:   summary,
				Text:      text,
				CreatedAt: cell(ecols, r, "created_at"),
			})
		}
	}

	sortActivity(data.History)
	return data
}



@@ 535,3 659,128 @@ func priorityRank(p string) int {
	}
	return n
}

// sortSubtasks orders an epic's children open-work-first: unclosed before
// closed, then by priority (0 highest), then id — closed subtasks sink to the
// bottom so the actionable ones lead.
func sortSubtasks(subs []BeadSubtask) {
	sort.SliceStable(subs, func(i, j int) bool {
		ci, cj := subs[i].Category == "closed", subs[j].Category == "closed"
		if ci != cj {
			return !ci // open (false) sorts before closed (true)
		}
		pi, pj := priorityRank(subs[i].Priority), priorityRank(subs[j].Priority)
		if pi != pj {
			return pi < pj
		}
		return subs[i].ID < subs[j].ID
	})
}

// sortActivity orders the merged history oldest-first (chronological). Timestamps
// share the "YYYY-MM-DD HH:MM:SS" shape across events and comments, so a lexical
// compare is a time compare; ties fall back to id-free but stable order.
func sortActivity(acts []BeadActivity) {
	sort.SliceStable(acts, func(i, j int) bool {
		return acts[i].CreatedAt < acts[j].CreatedAt
	})
}

// 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) {
	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)
	case "status_changed":
		if s := jsonField(newVal, "status"); s != "" {
			return "changed status to " + s, ""
		}
		return "changed status", ""
	case "updated":
		if pairs := jsonPairs(newVal); pairs != "" {
			return "updated " + pairs, ""
		}
		return "updated the issue", ""
	default:
		et := strings.ReplaceAll(strings.TrimSpace(eventType), "_", " ")
		if et == "" {
			et = "changed"
		}
		return et, ""
	}
}

// jsonField extracts one string-ish field from a JSON object fragment, or ""
// when the value is not a JSON object or the key is absent.
func jsonField(raw, key string) string {
	m := decodeJSONObject(raw)
	if m == nil {
		return ""
	}
	if v, ok := m[key]; ok {
		return scalarString(v)
	}
	return ""
}

// jsonPairs renders a JSON object fragment as "k to v, k2 to v2", used for the
// "updated …" summary. Keys are sorted for a deterministic line.
func jsonPairs(raw string) string {
	m := decodeJSONObject(raw)
	if len(m) == 0 {
		return ""
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, k+" to "+scalarString(m[k]))
	}
	return strings.Join(parts, ", ")
}

// decodeJSONObject parses raw into a map, tolerating the browse NULL placeholder
// and non-object payloads (returns nil rather than erroring).
func decodeJSONObject(raw string) map[string]any {
	raw = strings.TrimSpace(raw)
	if raw == "" || raw == "NULL" {
		return nil
	}
	var m map[string]any
	if err := json.Unmarshal([]byte(raw), &m); err != nil {
		return nil
	}
	return m
}

// scalarString renders a decoded JSON scalar the way a person would read it:
// integers without a trailing ".0", everything else via fmt.
func scalarString(v any) string {
	switch t := v.(type) {
	case string:
		return t
	case float64:
		if t == float64(int64(t)) {
			return strconv.FormatInt(int64(t), 10)
		}
		return strconv.FormatFloat(t, 'g', -1, 64)
	case bool:
		if t {
			return "true"
		}
		return "false"
	case nil:
		return ""
	default:
		return fmt.Sprintf("%v", t)
	}
}

M web/beads_test.go => web/beads_test.go +155 -0
@@ 352,3 352,158 @@ func TestBeadsDetailShowsCloseReason(t *testing.T) {
		}
	}
}

// --- epic mode + history -----------------------------------------------------

// beadsEpicFixture models an epic (i-epic) with three parent-child children —
// one closed, one open, one in-progress — plus a comment and three audit events
// on the epic, so both the subtask rollup and the merged history are exercised.
func beadsEpicFixture() *fakeSession {
	issues := &browse.RowPage{
		Columns: []string{"id", "title", "status", "priority", "issue_type", "assignee", "created_at", "is_blocked"},
		Rows: [][]string{
			{"i-epic", "Big Epic", "open", "1", "epic", "", "2024-01-01", "0"},
			{"i-c1", "Child one", "open", "2", "task", "alice", "2024-01-02", "0"},
			{"i-c2", "Child two", "closed", "1", "task", "bob", "2024-01-03", "0"},
			{"i-c3", "Child three", "in_progress", "0", "bug", "carol", "2024-01-04", "0"},
		},
		Total: 4,
	}
	// Each child is the "from" side of a parent-child edge pointing at the epic.
	deps := &browse.RowPage{
		Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"},
		Rows: [][]string{
			{"d1", "i-c1", "i-epic", "parent-child"},
			{"d2", "i-c2", "i-epic", "parent-child"},
			{"d3", "i-c3", "i-epic", "parent-child"},
		},
		Total: 3,
	}
	statuses := &browse.RowPage{
		Columns: []string{"name", "category"},
		Rows: [][]string{
			{"open", "open"}, {"in_progress", "in_progress"}, {"closed", "closed"},
		},
		Total: 3,
	}
	comments := &browse.RowPage{
		Columns: []string{"issue_id", "author", "text", "created_at"},
		Rows: [][]string{
			{"i-epic", "alice", "kickoff", "2024-01-05 09:00:00"},
			{"i-c1", "bob", "unrelated", "2024-01-06 09:00:00"},
		},
		Total: 2,
	}
	events := &browse.RowPage{
		Columns: []string{"id", "issue_id", "event_type", "actor", "old_value", "new_value", "comment", "created_at"},
		Rows: [][]string{
			{"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"},
			{"e9", "i-c1", "created", "Eugene", "NULL", "NULL", "NULL", "2024-01-02 08:00:00"},
		},
		Total: 4,
	}
	return &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		tables:   beadsTables(),
		rowsByTable: map[string]*browse.RowPage{
			"issues":          issues,
			"dependencies":    deps,
			"custom_statuses": statuses,
			"comments":        comments,
			"events":          events,
		},
	}
}

func TestBeadsEpicMode(t *testing.T) {
	v := &beadsView{}
	raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	d := raw.(*BeadsData)
	if d.Mode != "epic" {
		t.Fatalf("Mode = %q, want epic", d.Mode)
	}
	if d.SubtaskTotal != 3 || d.SubtaskDone != 1 {
		t.Errorf("rollup = %d/%d, want 1/3", d.SubtaskDone, d.SubtaskTotal)
	}
	if d.SubtaskPct() != 33 {
		t.Errorf("pct = %d, want 33", d.SubtaskPct())
	}
	// Sorted open-work-first (in_progress p0, then open p2), closed sinks last.
	gotIDs := []string{d.Subtasks[0].ID, d.Subtasks[1].ID, d.Subtasks[2].ID}
	wantIDs := []string{"i-c3", "i-c1", "i-c2"}
	for i := range wantIDs {
		if gotIDs[i] != wantIDs[i] {
			t.Errorf("subtask order = %v, want %v", gotIDs, wantIDs)
			break
		}
	}
	if d.Subtasks[2].Category != "closed" {
		t.Errorf("last subtask category = %q, want closed", d.Subtasks[2].Category)
	}
}

func TestBeadsHistoryMerge(t *testing.T) {
	v := &beadsView{}
	raw, err := v.Build(context.Background(), beadsEpicFixture(), nil, "main", url.Values{"issue": {"i-epic"}})
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	d := raw.(*BeadsData)

	// Only the epic's own comment shows in the Comments tab (not i-c1's).
	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)
	}
	for i := 1; i < len(d.History); i++ {
		if d.History[i-1].CreatedAt > d.History[i].CreatedAt {
			t.Errorf("history not time-sorted at %d: %q > %q", i, d.History[i-1].CreatedAt, d.History[i].CreatedAt)
		}
	}
	if d.History[0].Kind != "event" || d.History[0].Summary != "created the issue" {
		t.Errorf("first history = %+v, want created event", d.History[0])
	}
	last := d.History[len(d.History)-1]
	if last.Kind != "comment" || last.Text != "kickoff" {
		t.Errorf("last history = %+v, want the kickoff comment", last)
	}
	// Humanized change lines.
	var sawStatus, sawUpdate bool
	for _, a := range d.History {
		switch a.Summary {
		case "changed status to in_progress":
			sawStatus = true
		case "updated priority to 0":
			sawUpdate = true
		}
	}
	if !sawStatus || !sawUpdate {
		t.Errorf("history missing humanized change lines; status=%v update=%v", sawStatus, sawUpdate)
	}
}

func TestBeadsEpicViewRender(t *testing.T) {
	h := newHarness(t)
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = beadsEpicFixture()
	setViews(t, h, &beadsView{})

	rec := h.do("GET", "/~alice/db/view/beads?issue=i-epic", nil, nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("epic view: got %d, want 200; body=%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	for _, want := range []string{"Subtasks", "1 of 3 done", "epic-progress", "Child three", "History", "changed status to in_progress"} {
		if !strings.Contains(body, want) {
			t.Errorf("epic render missing %q", want)
		}
	}
}

A web/realdata_test.go => web/realdata_test.go +83 -0
@@ 0,0 1,83 @@
package web

import (
	"context"
	"net/url"
	"os"
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
)

// TestRealBeadsStore is a manual smoke test against a real beads database. It
// is skipped unless BEADS_REAL_DB points at a *bare* NBS store dir (the shape
// production serves — an embedded working store has a chunk journal and won't
// open; push it to a file remote first: `dolt push file:///path/bare main`).
// Optionally set BEADS_REAL_ISSUE=<id> to dump one issue's epic rollup and
// merged history. Kept because browse reaches into version-fragile dolt
// internals, so a module bump should be re-verified against real data here.
//
//	dolt push file:///tmp/bare main            # from the working store
//	BEADS_REAL_DB=/tmp/bare BEADS_REAL_ISSUE=<epic-id> \
//	  go test -tags gms_pure_go -run TestRealBeadsStore -v ./web/
func TestRealBeadsStore(t *testing.T) {
	path := os.Getenv("BEADS_REAL_DB")
	if path == "" {
		t.Skip("set BEADS_REAL_DB to a real beads noms store dir")
	}
	ctx := context.Background()
	dbh, err := browse.Open(ctx, path)
	if err != nil {
		t.Fatalf("open: %v", err)
	}
	defer dbh.Close()

	branches, err := dbh.Branches(ctx)
	if err != nil {
		t.Fatalf("branches: %v", err)
	}
	ref := browse.DefaultBranch(branches)
	t.Logf("branches=%v default=%q", branches, ref)

	tables, err := dbh.Tables(ctx, ref)
	if err != nil {
		t.Fatalf("tables: %v", err)
	}
	var names []string
	for _, tb := range tables {
		names = append(names, tb.Name)
	}
	t.Logf("tables=%v", names)

	issues, err := dbh.Rows(ctx, ref, "issues", 0, 5)
	if err != nil {
		t.Fatalf("rows: %v", err)
	}
	t.Logf("issues cols=%v total=%d", issues.Columns, issues.Total)

	// Drive the real view. With BEADS_REAL_ISSUE set to an epic id, dump the
	// subtask rollup and merged history so a human can eyeball the output.
	want := os.Getenv("BEADS_REAL_ISSUE")
	if want == "" {
		return
	}
	v := &beadsView{}
	raw, err := v.Build(ctx, dbh, nil, ref, url.Values{"issue": {want}})
	if err != nil {
		t.Fatalf("build: %v", err)
	}
	data := raw.(*BeadsData)
	if data.Issue == nil {
		t.Fatalf("issue %q not found", want)
	}
	t.Logf("mode=%q issue=%q type=%q", data.Mode, data.Issue.Title, data.Issue.IssueType)
	t.Logf("desc=%.80q closeReason=%.80q", data.Issue.Description, data.Issue.CloseReason)
	t.Logf("subtasks %d/%d done (%d%%):", data.SubtaskDone, data.SubtaskTotal, data.SubtaskPct())
	for _, s := range data.Subtasks {
		t.Logf("  - %s [%s/%s] %s @%s", s.ID, s.Category, s.Status, s.Title, s.Assignee)
	}
	t.Logf("history (%d entries):", len(data.History))
	for _, a := range data.History {
		t.Logf("  %s | %s: %s %s | %s", a.CreatedAt, a.Kind, a.Actor, a.Summary, a.Text)
	}
}

M web/templates/beads.html => web/templates/beads.html +103 -10
@@ 93,19 93,59 @@
.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); }

/* epic: badge, subtask rollup, progress meter */
.epic-badge { border-color: var(--lane-stalled); color: var(--bd-fg); text-transform: uppercase; letter-spacing: .04em; }
.epic-subtasks { margin-bottom: 1.25rem; }
.epic-progress { height: 6px; background: var(--bd-panel); border: 1px solid var(--bd-border); margin: .1rem 0 .6rem; }
.epic-progress-bar { height: 100%; background: var(--lane-linedup); }
.subtask-list li { padding: .3rem 0; }
/* a thin status accent on the left cap of each subtask row */
.subtask-list li.subtask { border-left: 2px solid var(--bd-border); padding-left: .5rem; }
.subtask-list li.subtask.in_progress { border-left-color: var(--lane-rolling); }
.subtask-list li.subtask.closed { border-left-color: var(--lane-past); }
.subtask-list li.subtask.closed a code { text-decoration: line-through; }

/* activity: Comments | History tabs (pure-CSS radio switch, no JS) */
.bead-activity .acttab-radio { position: absolute; opacity: 0; pointer-events: none; }
.acttabs { display: flex; gap: 0; border-bottom: 1px solid var(--bd-border); margin-bottom: .75rem; }
.acttabs label {
  padding: .35rem .85rem; cursor: pointer; color: var(--bd-muted);
  border: 1px solid transparent; border-bottom: none; margin-bottom: -1px;
}
#acttab-comments:checked ~ .acttabs label[for="acttab-comments"],
#acttab-history:checked ~ .acttabs label[for="acttab-history"] {
  color: var(--bd-fg); background: var(--bd-panel);
  border-color: var(--bd-border); border-bottom: 1px solid var(--bd-panel);
}
.bead-activity .actpanel { display: none; }
#acttab-comments:checked ~ .panel-comments { display: block; }
#acttab-history:checked ~ .panel-history { display: block; }

.bead-timeline { list-style: none; padding-left: 0; margin: 0; border-left: 2px solid var(--bd-border); }
.bead-timeline .tl-item { position: relative; padding: .2rem 0 .5rem .8rem; }
.bead-timeline .tl-item::before {
  content: ""; position: absolute; left: -5px; top: .5rem;
  width: 8px; height: 8px; background: var(--bd-muted); border: 1px solid var(--bd-bg);
}
.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; }
</style>

<div class="beads">
<h2><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}">~{{.Repo.OwnerName}}/{{.Repo.Name}}</a> &middot; parade</h2>
{{template "viewtabs" (dict "Repo" .Repo "Views" .Views "Current" "beads" "Ref" .Ref)}}

{{if eq .Data.Mode "detail"}}
{{/* ---------------- detail pane ---------------- */}}
{{if or (eq .Data.Mode "detail") (eq .Data.Mode "epic")}}
{{/* ------------- detail / epic pane ------------- */}}
<p><a href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/view/beads">&larr; Back to the parade</a></p>
{{with .Data.Issue}}
<div class="bead-detail">
  <h2 class="ticket-title">
    <code>{{.ID}}</code> <span>{{.Title}}</span>
    {{if eq $.Data.Mode "epic"}}<span class="blabel epic-badge">epic</span>{{end}}
    <span class="lane-label" style="--lane: {{.Accent}};"><span class="swatch"></span>{{.Lane}}</span>
  </h2>
  <div class="r-meta mb-3">


@@ 126,7 166,6 @@
      {{if .StartedAt}}<tr><td class="field-label">Started</td><td>{{.StartedAt}}</td></tr>{{end}}
      {{if .UpdatedAt}}<tr><td class="field-label">Updated</td><td>{{.UpdatedAt}}</td></tr>{{end}}
      {{if .ClosedAt}}<tr><td class="field-label">Closed</td><td>{{.ClosedAt}}</td></tr>{{end}}
      {{if .CloseReason}}<tr><td class="field-label">Close reason</td><td>{{.CloseReason}}</td></tr>{{end}}
    </tbody>
  </table>
  {{if .Description}}<div class="field-label">Description</div><pre class="field-body">{{.Description}}</pre>{{end}}


@@ 135,6 174,29 @@
  {{if .Notes}}<div class="field-label">Notes</div><pre class="field-body">{{.Notes}}</pre>{{end}}
</div>

{{if eq $.Data.Mode "epic"}}
<div class="epic-subtasks">
  <h4>Subtasks{{if $.Data.SubtaskTotal}} <small class="text-muted">{{$.Data.SubtaskDone}} of {{$.Data.SubtaskTotal}} done</small>{{end}}</h4>
  {{if $.Data.SubtaskTotal}}
  <div class="epic-progress" title="{{$.Data.SubtaskPct}}% complete">
    <div class="epic-progress-bar" style="width: {{$.Data.SubtaskPct}}%"></div>
  </div>
  <ul class="bead-deplist subtask-list">
    {{range $.Data.Subtasks}}
    <li class="subtask {{.Category}}">
      <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/view/beads?issue={{.ID | urlquery}}"><code>{{.ID}}</code></a>
      {{if .Title}}— {{.Title}}{{end}}
      {{if .PriorityLabel}}<span class="blabel prio">{{.PriorityLabel}}</span>{{end}}
      <span class="blabel muted">{{.Status}}</span>
      {{if .Assignee}}<span class="blabel assignee">@{{.Assignee}}</span>{{end}}
      {{if .Blocked}}<span class="blabel block">&#128679;</span>{{end}}
    </li>
    {{end}}
  </ul>
  {{else}}<p class="beads-empty">No subtasks linked to this epic.</p>{{end}}
</div>
{{end}}

<div class="row">
  <div class="col-md-6">
    <h4>Depends on</h4>


@@ 167,15 229,46 @@
  </div>
</div>

<h4>Comments</h4>
{{if $.Data.Comments}}
{{range $.Data.Comments}}
<div class="bead-comment">
  <div class="c-head"><strong>{{.Author}}</strong> {{if .CreatedAt}}&middot; {{.CreatedAt}}{{end}}</div>
  <div class="c-body">{{.Text}}</div>
<div class="bead-activity">
  <input type="radio" name="acttab" id="acttab-comments" class="acttab-radio" checked>
  <input type="radio" name="acttab" id="acttab-history" class="acttab-radio">
  <div class="acttabs">
    <label for="acttab-comments">Comments</label>
    <label for="acttab-history">History</label>
  </div>

  <div class="actpanel panel-comments">
    {{if $.Data.Comments}}
    {{range $.Data.Comments}}
    <div class="bead-comment">
      <div class="c-head"><strong>{{.Author}}</strong> {{if .CreatedAt}}&middot; {{.CreatedAt}}{{end}}</div>
      <div class="c-body">{{.Text}}</div>
    </div>
    {{end}}
    {{else}}<p class="beads-empty">No comments.</p>{{end}}
  </div>

  <div class="actpanel panel-history">
    {{if $.Data.History}}
    <ul class="bead-timeline">
      {{range $.Data.History}}
      <li class="tl-item tl-{{.Kind}}">
        <div class="tl-head">
          <strong>{{.Actor}}</strong> {{.Summary}}
          {{if .CreatedAt}}<span class="tl-when">&middot; {{.CreatedAt}}</span>{{end}}
        </div>
        {{if .Text}}<div class="tl-body">{{.Text}}</div>{{end}}
      </li>
      {{end}}
    </ul>
    {{else}}<p class="beads-empty">No activity yet.</p>{{end}}
  </div>
</div>

{{if .CloseReason}}
<h4>Close reason</h4>
<pre class="field-body">{{.CloseReason}}</pre>
{{end}}
{{else}}<p class="beads-empty">No comments.</p>{{end}}
{{else}}
<div class="alert alert-warning">Issue not found.</div>
{{end}}