From 2ee66fb89c83da809c295f2b8bb52f630e55f629 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 19 Jul 2026 21:13:43 +0300 Subject: [PATCH] feat(web/beads): epic view with subtask rollup, tabbed Comments/History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/beads.go | 277 +++++++++++++++++++++++++++++++++++++-- web/beads_test.go | 155 ++++++++++++++++++++++ web/realdata_test.go | 83 ++++++++++++ web/templates/beads.html | 113 ++++++++++++++-- 4 files changed, 604 insertions(+), 24 deletions(-) create mode 100644 web/realdata_test.go diff --git a/web/beads.go b/web/beads.go index aa264b86b88390fdac7679a33247196d2689708c..e4e8289632869bfe191f97eaf6321345cd8834a1 100644 --- a/web/beads.go +++ b/web/beads.go @@ -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) + } +} diff --git a/web/beads_test.go b/web/beads_test.go index bbce5e00e91fa1214eb4e2b063cb254e7c776e2a..d5fb3a6f614e101809f6c3083a37e6d07a6e26a9 100644 --- a/web/beads_test.go +++ b/web/beads_test.go @@ -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) + } + } +} diff --git a/web/realdata_test.go b/web/realdata_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4576a96480bd152814fcc2ec5bf11213b2bebb58 --- /dev/null +++ b/web/realdata_test.go @@ -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= 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= \ +// 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) + } +} diff --git a/web/templates/beads.html b/web/templates/beads.html index 108700774621329cf894528b5e3072ea5429a304..478bf64adce9a153f7a8f106c46234fac76887e5 100644 --- a/web/templates/beads.html +++ b/web/templates/beads.html @@ -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; }

~{{.Repo.OwnerName}}/{{.Repo.Name}} · parade

{{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 ------------- */}}

← Back to the parade

{{with .Data.Issue}}

{{.ID}} {{.Title}} + {{if eq $.Data.Mode "epic"}}epic{{end}} {{.Lane}}

@@ -126,7 +166,6 @@ {{if .StartedAt}}Started{{.StartedAt}}{{end}} {{if .UpdatedAt}}Updated{{.UpdatedAt}}{{end}} {{if .ClosedAt}}Closed{{.ClosedAt}}{{end}} - {{if .CloseReason}}Close reason{{.CloseReason}}{{end}} {{if .Description}}
Description
{{.Description}}
{{end}} @@ -135,6 +174,29 @@ {{if .Notes}}
Notes
{{.Notes}}
{{end}}
+{{if eq $.Data.Mode "epic"}} +
+

Subtasks{{if $.Data.SubtaskTotal}} {{$.Data.SubtaskDone}} of {{$.Data.SubtaskTotal}} done{{end}}

+ {{if $.Data.SubtaskTotal}} +
+
+
+
    + {{range $.Data.Subtasks}} +
  • + {{.ID}} + {{if .Title}}— {{.Title}}{{end}} + {{if .PriorityLabel}}{{.PriorityLabel}}{{end}} + {{.Status}} + {{if .Assignee}}@{{.Assignee}}{{end}} + {{if .Blocked}}🚧{{end}} +
  • + {{end}} +
+ {{else}}

No subtasks linked to this epic.

{{end}} +
+{{end}} +

Depends on

@@ -167,15 +229,46 @@
-

Comments

-{{if $.Data.Comments}} -{{range $.Data.Comments}} -
-
{{.Author}} {{if .CreatedAt}}· {{.CreatedAt}}{{end}}
-
{{.Text}}
+
+ + +
+ + +
+ +
+ {{if $.Data.Comments}} + {{range $.Data.Comments}} +
+
{{.Author}} {{if .CreatedAt}}· {{.CreatedAt}}{{end}}
+
{{.Text}}
+
+ {{end}} + {{else}}

No comments.

{{end}} +
+ +
+ {{if $.Data.History}} +
    + {{range $.Data.History}} +
  • +
    + {{.Actor}} {{.Summary}} + {{if .CreatedAt}}· {{.CreatedAt}}{{end}} +
    + {{if .Text}}
    {{.Text}}
    {{end}} +
  • + {{end}} +
+ {{else}}

No activity yet.

{{end}} +
+ +{{if .CloseReason}} +

Close reason

+
{{.CloseReason}}
{{end}} -{{else}}

No comments.

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