From 2afa7aed19ee57010e85bb5349be3234a676c1d9 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Fri, 14 Aug 2026 09:04:54 +0900 Subject: [PATCH] feat: daily Produced pane reads as a timeline, tabbed by kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The day pane grouped every output into kind sections, so a day read as four stacked lists instead of as the day it was — and on a real day (570 outputs) the later sections sat hundreds of lines below the fold, reachable only by scrolling past everything else. Rows now come out in the order the outputs first appeared, each stamped with that time. Kinds became TABS: All plus one per kind the scope produced, each carrying its count so the bar doubles as the day's rollup. tab/shift+tab switches — the keys that rotate preview modes on a session row, which a day row has none of (rowSupportsPreviewModes already said so). As a bonus, tab on a day row no longer silently rotates sessPreviewMode. Chronological is NOT the natural insertion order: a session's Refs are sorted first-seen DESCENDING, so the timeline only exists because of the explicit sort. Outputs with no recorded entry (plan slugs; refs extracted before FirstSeen existed) fall back to the producing session's time rather than a zero that would sink them to the bottom and read as "produced last" — those stamps are marked `~` so the row does not assert a minute it does not know. A first mention on another date spells the date out. Three traps this had to get right: - a.dayOutputRows holds the FILTERED slice, because that is what dayOutputsCursor indexes — rendering a filter while indexing the full list would make enter/o/y/x act on a different row than the highlighted one. The cursor resets on a tab switch for the same reason. - the preview cache key carries the tab; without it the next updateSessionPreview() sees a matching key and the pane reverts. - the active tab stays in the bar even on a day that produced none of it, so walking dates under one kind filter keeps comparing like with like. --- README.md | 4 + internal/tui/app.go | 23 ++- internal/tui/daily_test.go | 32 +++- internal/tui/daypane.go | 338 +++++++++++++++++++++++++++++------ internal/tui/daypane_test.go | 255 +++++++++++++++++++++++++- internal/tui/help.go | 8 +- 6 files changed, 594 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index a18b0b6..b3e1ba1 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,10 @@ The list nests three tiers — **day → project → session** — each folding The preview always shows **what that scope produced** — PRs, Jira issues, artifacts and plans, one row each. Selecting a date row shows the day's outputs; selecting a project row narrows to that project on that day. The sessions themselves are not listed in the pane: they are one row below in the list. +Rows read as a **timeline**: every output in the order it first appeared, stamped with that time, kinds interleaved — a day is lived in time, and "what happened after the PR went up" is the question the pane is usually asked. An output whose first mention falls on another date (a long-lived session carrying a ref in) shows its full date rather than a bare time that would belong to the wrong day, and a `~` marks a time taken from the producing session because the output records no entry of its own (plan slugs, and refs extracted by an older build). + +To read one kind at a time, the pane is **tabbed**: `All` plus a tab for each kind the scope produced (`PRs`, `Jira`, `Artifacts`, `Plans`), each carrying its own count so the bar doubles as the day's rollup. `tab`/`shift+tab` switches — the same keys that rotate preview modes on a session row, since a day row has no preview modes to rotate. Kinds are tabs rather than sections in one list because a busy day produces 500+ outputs, and stacked sections put the later kinds hundreds of lines below the fold. Every tab keeps the one chronology, and the selected tab is sticky as you walk between dates, so "what PRs did each day produce" stays a single keypress per day; a day with none of that kind says so rather than silently falling back to `All`. + Every output row carries the session that produced it as an anchor (`a1b2c3 · ~/src/repo`). Focus the preview and press `Enter` on a row to land in that conversation **at the message where the output first appeared** — the digest tells you *what* came out, and the anchor is how you get to *how*. `o` opens the output itself (a PR, Jira issue or artifact in the browser), `y` copies its URL or path, and `x` lists every action that applies to the row (see [Output Row Actions](#output-row-actions-x)). Outputs referenced from several sessions collapse to one row with a `+N` spread marker, anchored to the earliest session (where the work happened, not where it was later quoted) — and the jump lands in *that* session, at *its* first mention. Sessions are bucketed by the calendar day of their **last** activity. A session that spans midnight appears once, under the day it was last active — it is never duplicated across dates. diff --git a/internal/tui/app.go b/internal/tui/app.go index 1b7716c..1cbd6f3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -320,6 +320,7 @@ type App struct { dayOutputRows []dayOutputRow // outputs shown in the daily view's day pane, in cursor order dayOutputsCursor int // cursor within the day pane's output list dayOutputsCacheID string // day key the cursor currently tracks + dayOutputTabKind session.OutputKind // day pane's active kind tab ("" = the All timeline) preDailyGroupMode int // grouping to restore when the daily view is toggled back off dailyPreviewMode sessPreview // preview mode remembered for the daily view browserPreviewMode sessPreview // preview mode remembered for every other grouping @@ -2323,6 +2324,14 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { sp.Focus = false return a, a.updateSessionPreview() } + // A row that owns the day pane has no preview modes to rotate — its pane + // is that scope's outputs whatever sessPreviewMode says (the same reason + // rowSupportsPreviewModes blocks the digits there). Tab switches the + // pane's KIND instead, which is the only axis it actually has. + if a.selectedOwnsDayPane() { + a.cycleDayOutputTab(+1) + return a, nil + } a.cycleSessionPreviewMode() return a, a.updateSessionPreview() case km.Session.PreviewBack: @@ -2331,6 +2340,10 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { sp.Focus = false return a, a.updateSessionPreview() } + if a.selectedOwnsDayPane() { + a.cycleDayOutputTab(-1) + return a, nil + } a.cycleSessionPreviewModeReverse() return a, a.updateSessionPreview() } @@ -5391,9 +5404,9 @@ func (a *App) updateSessionPreview() tea.Cmd { // produced — rather than an arbitrary child's detail. Drilling into a // child session is what opens the per-session Outputs digest. // The pane is the day's outputs regardless of preview mode, so the mode - // is not part of the key; the cursor and focus are, so moving the - // highlight re-renders. - cacheKey := fmt.Sprintf("day:%s:%d:%d:%t", di.dayKey, len(di.sessions), a.dayOutputsCursor, a.sessSplit.Focus) + // is not part of the key; the cursor, focus and KIND TAB are, so moving + // the highlight or switching tabs re-renders. + cacheKey := fmt.Sprintf("day:%s:%d:%d:%t:%s", di.dayKey, len(di.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind) if cacheKey == a.sessSplit.CacheKey { return nil } @@ -5407,8 +5420,8 @@ func (a *App) updateSessionPreview() tea.Cmd { // day's work in one project, so its pane is that slice's outputs — not a // representative session's, and not the generic project summary. if pi.dayKey != "" { - cacheKey := fmt.Sprintf("dayproj:%s:%s:%d:%d:%t", pi.dayKey, pi.basePath, - len(pi.sessions), a.dayOutputsCursor, a.sessSplit.Focus) + cacheKey := fmt.Sprintf("dayproj:%s:%s:%d:%d:%t:%s", pi.dayKey, pi.basePath, + len(pi.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind) if cacheKey == a.sessSplit.CacheKey { return nil } diff --git a/internal/tui/daily_test.go b/internal/tui/daily_test.go index d54b487..cabfe5c 100644 --- a/internal/tui/daily_test.go +++ b/internal/tui/daily_test.go @@ -534,7 +534,10 @@ func TestDayPreviewCollapsesRepeatedOutputs(t *testing.T) { } } -func TestDayPreviewOrdersResultsBeforePlans(t *testing.T) { +// TestDayPreviewTabsCoverEveryKindProduced replaces the old kind-ordering test: +// kinds are no longer sections in one list, they are tabs, and the bar must +// offer exactly the kinds the scope produced (All plus those). +func TestDayPreviewTabsCoverEveryKindProduced(t *testing.T) { sessions := []session.Session{{ ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: dayOf(0), PlanSlugs: []string{"a-plan"}, @@ -546,13 +549,26 @@ func TestDayPreviewOrdersResultsBeforePlans(t *testing.T) { di := buildDailyItems(sessions, nil)[0].(dayItem) rows := buildDayOutputRows(di) - want := []session.OutputKind{session.OutputPR, session.OutputArtifact, session.OutputPlan} - if len(rows) != len(want) { - t.Fatalf("expected %d rows, got %d", len(want), len(rows)) - } - for i, k := range want { - if rows[i].out.Kind != k { - t.Fatalf("row %d: got %s, want %s", i, rows[i].out.Kind, k) + tabs := dayOutputTabsFor(rows, "") + var got []string + for _, tb := range tabs { + got = append(got, tb.label) + } + // All first, then the produced kinds in outputKindRank order. Jira is absent + // because the day produced none — a tab that can only ever be empty would + // make the bar say more than the day does. + want := []string{"All", "PRs", "Artifacts", "Plans"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("tabs = %v, want %v", got, want) + } + for _, tb := range tabs { + if tb.kind == "" { + continue + } + for _, r := range filterDayOutputRows(rows, tb) { + if r.out.Kind != tb.kind { + t.Errorf("tab %q leaked a %s row", tb.label, r.out.Kind) + } } } } diff --git a/internal/tui/daypane.go b/internal/tui/daypane.go index 464ba2c..fa92142 100644 --- a/internal/tui/daypane.go +++ b/internal/tui/daypane.go @@ -29,6 +29,93 @@ type dayOutputRow struct { // A PR gets discussed across several sessions; the row is the PR, not each // mention, and this says how widely it spread. sessions int + // when is the moment the output FIRST appeared — the day's timeline is built + // on it and the time-sorted layout prints it. Refs extracted before + // FirstSeen was recorded (and plan slugs, which have no entry at all) fall + // back to the anchor session's ModTime: sinking them to the bottom of the + // day with a zero time would read as "produced last", which is worse than + // approximating with the session they came from. + when time.Time + // whenApprox marks a `when` that came from that fallback, so the timeline + // can say "about here" (`~`) instead of stating a minute it does not know. + whenApprox bool +} + +// dayOutputTab is one kind filter over what a scope produced. The pane is a +// tabbed surface rather than one grouped list: a busy day produces 570 outputs, +// and with kinds stacked as sections the later groups sit hundreds of lines +// below the fold — reachable only by scrolling past everything else. A tab +// makes each kind one keypress away instead. +type dayOutputTab struct { + label string + // kind is the OutputKind this tab shows; empty means the timeline of + // everything, which is the tab the pane opens on. + kind session.OutputKind +} + +// dayOutputTabAll is the timeline: every output the scope produced, in the order +// it first appeared, kinds interleaved. A day is lived in time, and "what +// happened after the PR went up" is what the pane is usually asked. +var dayOutputTabAll = dayOutputTab{label: "All"} + +// dayOutputTabOrder lists the kind tabs in the order they are offered, matching +// outputKindRank (results before working material). +var dayOutputTabOrder = []dayOutputTab{ + {label: "PRs", kind: session.OutputPR}, + {label: "Jira", kind: session.OutputJira}, + {label: "Artifacts", kind: session.OutputArtifact}, + {label: "Plans", kind: session.OutputPlan}, +} + +// dayOutputTabsFor returns All plus a tab for every kind the scope actually +// produced. Offering a tab that is always empty would make the bar say more +// than the day does (the inspector's availableInspectorTabs does the same). +// +// active is kept in the bar even when this scope produced none of it — the tab +// is sticky as you walk dates, and dropping it would silently snap back to All +// and break the date-to-date comparison that stickiness exists for (the +// inspector keeps an explicitly-chosen tab the same way). +func dayOutputTabsFor(rows []dayOutputRow, active session.OutputKind) []dayOutputTab { + present := make(map[session.OutputKind]bool, len(rows)) + for _, r := range rows { + present[r.out.Kind] = true + } + tabs := []dayOutputTab{dayOutputTabAll} + for _, t := range dayOutputTabOrder { + if present[t.kind] || t.kind == active { + tabs = append(tabs, t) + } + } + return tabs +} + +// filterDayOutputRows narrows rows to one tab's kind. All returns rows +// unchanged. The result is what the pane both renders AND indexes with +// dayOutputsCursor — filtering at render time only would leave Enter/o/y/x +// acting on a different output than the highlighted one. +func filterDayOutputRows(rows []dayOutputRow, tab dayOutputTab) []dayOutputRow { + if tab.kind == "" { + return rows + } + out := make([]dayOutputRow, 0, len(rows)) + for _, r := range rows { + if r.out.Kind == tab.kind { + out = append(out, r) + } + } + return out +} + +// dayOutputTabIndex locates the active tab in the bar. dayOutputTabsFor always +// includes the active kind, so the All fallback here only fires for a kind that +// is not a tab at all (a config or ordering mismatch), never for an empty day. +func dayOutputTabIndex(tabs []dayOutputTab, active session.OutputKind) int { + for i, t := range tabs { + if t.kind == active { + return i + } + } + return 0 } // buildDayOutputRows collects the day's outputs from state the scan and the ref @@ -41,12 +128,15 @@ type dayOutputRow struct { // row keeps its FIRST session as the anchor — where the work actually happened, // as opposed to wherever it was later quoted. // -// Rows are chronological: a day reads as a journal, the opposite of the list -// rows (which lead with the most recent). +// Rows come out as a timeline of first appearances. Chronological is NOT the +// natural insertion order — a session's Refs are sorted first-seen DESCENDING +// (session.SortRefs), so the timeline only exists because of the explicit sort +// below. Kind filtering is a separate step (filterDayOutputRows) so every tab +// keeps this one order. func buildDayOutputRows(di dayItem) []dayOutputRow { var rows []dayOutputRow byKey := map[string]int{} // identity → index into rows - add := func(o session.SessionOutput, s session.Session) { + add := func(o session.SessionOutput, s session.Session, ts time.Time, approx bool) { key := string(o.Kind) + "\x00" + o.Title if i, ok := byKey[key]; ok { rows[i].sessions++ @@ -55,32 +145,63 @@ func buildDayOutputRows(di dayItem) []dayOutputRow { // must stay with the anchor session, since a uuid from a later // session does not exist in the anchor's transcript and the jump // would silently miss. - if !o.Last.IsZero() && (rows[i].out.Last.IsZero() || o.Last.Before(rows[i].out.Last)) { - rows[i].out.Last = o.Last + if !ts.IsZero() && (rows[i].when.IsZero() || ts.Before(rows[i].when)) { + rows[i].when, rows[i].whenApprox = ts, approx } return } byKey[key] = len(rows) rows = append(rows, dayOutputRow{ out: o, sessID: s.ID, shortID: s.ShortID, project: s.ProjectName, sessions: 1, + when: ts, whenApprox: approx, }) } for _, s := range chronological(di.sessions) { for _, r := range s.Refs { - add(session.RefOutput(r), s) + o := session.RefOutput(r) + ts, approx := outputWhen(o.First, s) + add(o, s, ts, approx) } for _, slug := range s.PlanSlugs { + // A plan slug records no entry at all, so its time is always the + // session's. add(session.SessionOutput{ Kind: session.OutputPlan, Title: slug, Last: s.ModTime, Count: 1, - }, s) + }, s, s.ModTime, true) } } - // Group by kind (results before working material), preserving the - // chronological order established above within each kind. + sortDayOutputRowsByTime(rows) + return rows +} + +// outputWhen resolves the moment an output first appeared, falling back to the +// producing session's last-activity time when the ref carries no timestamp +// (extracted by an older build). A zero time would sort to one end of the day +// and read as a claim about when the work happened; the session's own time is a +// bounded approximation instead. The bool says which of the two it returned, so +// the timeline can mark the approximation rather than assert a minute. +func outputWhen(first time.Time, s session.Session) (time.Time, bool) { + if !first.IsZero() { + return first, false + } + return s.ModTime, true +} + +// sortDayOutputRowsByTime orders rows oldest-first — a day reads as a journal, +// the opposite of the list rows (which lead with the most recent). Ties break +// on title so the order is stable across rebuilds rather than depending on map +// iteration. +func sortDayOutputRowsByTime(rows []dayOutputRow) { sort.SliceStable(rows, func(i, j int) bool { - return outputKindRank(rows[i].out.Kind) < outputKindRank(rows[j].out.Kind) + a, b := rows[i].when, rows[j].when + if a.IsZero() != b.IsZero() { + return b.IsZero() // rows with no time at all go last + } + if !a.Equal(b) { + return a.Before(b) + } + return rows[i].out.Title < rows[j].out.Title }) - return rows } // outputKindRank mirrors session.SortOutputs' kind ordering for row structs, @@ -112,16 +233,13 @@ func (a *App) updateDayPreview(di dayItem) { contentH := max(a.height-3, 1) // Reset the cursor when the day changes so it never points past a shorter - // day's row list. + // day's row list. The TAB is deliberately kept — walking dates under one + // kind filter is what makes it useful. if a.dayOutputsCacheID != di.dayKey { a.dayOutputsCursor = 0 a.dayOutputsCacheID = di.dayKey } - rows := buildDayOutputRows(di) - if a.dayOutputsCursor >= len(rows) { - a.dayOutputsCursor = 0 - } - a.dayOutputRows = rows + all := buildDayOutputRows(di) // Recreate the viewport only on a size change. Rebuilding it every call // would reset YOffset to 0, and since cursor movement re-renders, every @@ -134,7 +252,7 @@ func (a *App) updateDayPreview(di dayItem) { subtitle := di.day.Format("Mon, Jan 2 2006") summary := fmt.Sprintf("%s across %s · %d messages", plural(len(di.sessions), "session"), plural(di.projects, "project"), di.totalMsgs) - a.sessSplit.Preview.SetContent(a.renderOutputsPane(title, subtitle, summary, rows, previewW)) + a.sessSplit.Preview.SetContent(a.renderOutputsPane(title, subtitle, summary, di.day, all, previewW)) } // updateDayProjectPreview renders the middle tier of the daily tree: one day's @@ -149,11 +267,7 @@ func (a *App) updateDayProjectPreview(pi projectItem) { a.dayOutputsCursor = 0 a.dayOutputsCacheID = cacheID } - rows := buildDayOutputRows(dayItem{sessions: pi.sessions}) - if a.dayOutputsCursor >= len(rows) { - a.dayOutputsCursor = 0 - } - a.dayOutputRows = rows + all := buildDayOutputRows(dayItem{sessions: pi.sessions}) if a.sessSplit.Preview.Width != previewW || a.sessSplit.Preview.Height != contentH { a.sessSplit.Preview = viewport.New(previewW, contentH) @@ -164,16 +278,42 @@ func (a *App) updateDayProjectPreview(pi projectItem) { } summary := fmt.Sprintf("%s · %d messages on this day", plural(len(pi.sessions), "session"), pi.totalMsgs) - a.sessSplit.Preview.SetContent(a.renderOutputsPane(pi.displayName, subtitle, summary, rows, previewW)) + a.sessSplit.Preview.SetContent(a.renderOutputsPane(pi.displayName, subtitle, summary, dayKeyTime(pi.dayKey), all, previewW)) +} + +// dayKeyTime parses a "2006-01-02" fold key back into a local date. A zero time +// on failure is harmless: it only makes the timeline print full dates instead +// of bare times. +func dayKeyTime(key string) time.Time { + t, err := time.ParseInLocation("2006-01-02", key, time.Local) + if err != nil { + return time.Time{} + } + return t } // renderOutputsPane draws a "what this produced" pane for any scope in the // daily tree: a whole day, or one project within it. Only the header text -// differs — the outputs list is the same shape at every level. -func (a *App) renderOutputsPane(title, subtitle, summary string, rows []dayOutputRow, width int) string { +// differs — the outputs list is the same shape at every level. day is the +// calendar date the scope covers, which the timeline uses to decide whether a +// row's time needs its date spelled out. +// +// all is every row the scope produced; the active tab narrows it. The narrowed +// slice is stored on the App because that is what dayOutputsCursor indexes — +// Enter/o/y/x all resolve through it, so the rendered list and the actionable +// list must be the same slice. +func (a *App) renderOutputsPane(title, subtitle, summary string, day time.Time, all []dayOutputRow, width int) string { section := lipgloss.NewStyle().Bold(true).Foreground(colorAccent) var sb strings.Builder + tabs := dayOutputTabsFor(all, a.dayOutputTabKind) + active := tabs[dayOutputTabIndex(tabs, a.dayOutputTabKind)] + rows := filterDayOutputRows(all, active) + if a.dayOutputsCursor >= len(rows) { + a.dayOutputsCursor = 0 + } + a.dayOutputRows = rows + sb.WriteString(lipgloss.NewStyle().Bold(true).Foreground(colorPrimary).Render(title)) if subtitle != "" { sb.WriteString(dimStyle.Render(" " + subtitle)) @@ -182,27 +322,32 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, rows []dayOutpu sb.WriteString(dimStyle.Render(summary)) sb.WriteString("\n\n") + sb.WriteString(a.renderDayOutputTabs(tabs, active, all) + "\n") + heading := fmt.Sprintf("Produced (%d)", len(rows)) if len(rows) > 0 && a.sessSplit.Focus { heading += " ↵:jump to first mention o:open y:copy x:actions" } sb.WriteString(section.Render(heading) + "\n") - if len(rows) == 0 { + switch { + case len(rows) == 0 && active.kind != "": + // The tab is sticky across dates on purpose, so an empty day under a + // kind filter is a real answer ("this day produced no PRs"), not a + // reason to silently fall back to All. + sb.WriteString(dimStyle.Render(" nothing of this kind on this day") + "\n\n") + case len(rows) == 0: // Refs resolve lazily, so "nothing yet" is the honest phrasing: rows fill // in as the background extract lands rather than this being a verdict. sb.WriteString(dimStyle.Render(" no references or plans recorded yet") + "\n\n") - } else { - lastKind := session.OutputKind("") + default: + // One run of rows in first-appearance order, each stamped with when it + // appeared. Kind headings are deliberately absent at every tab: under a + // kind tab they would repeat one word, and in All — with kinds + // interleaved — they would fire on nearly every row. The glyph already + // says what a row is. for i, r := range rows { - if r.out.Kind != lastKind { - if lastKind != "" { - sb.WriteString("\n") - } - sb.WriteString(dimStyle.Bold(true).Render(" "+outputSection(r.out.Kind)) + "\n") - lastKind = r.out.Kind - } - sb.WriteString(dayOutputLine(r, width, i == a.dayOutputsCursor && a.sessSplit.Focus) + "\n") + sb.WriteString(dayOutputLine(r, day, width, i == a.dayOutputsCursor && a.sessSplit.Focus) + "\n") } sb.WriteString("\n") } @@ -215,23 +360,71 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, rows []dayOutpu // belongs to the list (it folds the row); focused, the keys are this pane's, // and saying otherwise sent people to the wrong action. if a.sessSplit.Focus { - sb.WriteString(dimStyle.Render("↵ jumps to where it first appeared • o opens it • y copies • x lists every action for the row • ↑↓ moves between outputs")) + sb.WriteString(dimStyle.Render("↵ jumps to where it first appeared • o opens it • y copies • x lists every action for the row • tab switches kind • ↑↓ moves between outputs")) } else { - sb.WriteString(dimStyle.Render("↵/o folds this row • tab focuses this pane")) + sb.WriteString(dimStyle.Render("↵/o folds this row • tab switches kind • → focuses this pane")) } return sb.String() } -// dayOutputLine renders one output row: cursor, kind glyph, title, then the -// producing session as a dimmed anchor. -func dayOutputLine(r dayOutputRow, width int, selected bool) string { +// renderDayOutputTabs draws the kind tab bar, each tab carrying its own count so +// the bar doubles as the day's rollup — you can see there were 424 PRs without +// opening that tab. +func (a *App) renderDayOutputTabs(tabs []dayOutputTab, active dayOutputTab, all []dayOutputRow) string { + counts := make(map[session.OutputKind]int, len(tabs)) + for _, r := range all { + counts[r.out.Kind]++ + } + hl := lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + parts := make([]string, 0, len(tabs)) + for _, t := range tabs { + n := len(all) + if t.kind != "" { + n = counts[t.kind] + } + label := fmt.Sprintf("%s %d", t.label, n) + if t == active { + parts = append(parts, hl.Render("["+label+"]")) + continue + } + parts = append(parts, dimStyle.Render(" "+label+" ")) + } + return " " + strings.Join(parts, " ") +} + +// dayOutputTime formats a row's first-appearance stamp for the timeline. Within +// the scope's own date a bare time is enough; an output whose first mention +// lands on another day (a long-lived session, or a ref carried in from an +// earlier one) spells the date out rather than showing a time that silently +// belongs elsewhere. approx times — taken from the producing session because +// the output records no entry of its own — are marked `~`, since printing them +// bare would state a minute the row does not actually know. +func dayOutputTime(when, day time.Time, approx bool) string { + if when.IsZero() { + return " -- " + } + lead := " " + if approx { + lead = "~" + } + if !day.IsZero() && (when.Year() != day.Year() || when.YearDay() != day.YearDay()) { + return lead + when.Format("Jan 2 15:04") + } + return lead + when.Format("15:04") + " " +} + +// dayOutputLine renders one output row: cursor, first-appearance stamp, kind +// glyph, title, then the producing session as a dimmed anchor. The stamp leads +// because it is the column the eye scans down — every tab is a timeline. +func dayOutputLine(r dayOutputRow, day time.Time, width int, selected bool) string { cursor := " " titleStyle := lipgloss.NewStyle().Bold(true) if selected { cursor = lipgloss.NewStyle().Foreground(colorBorderFocused).Bold(true).Render("> ") titleStyle = titleStyle.Foreground(colorBorderFocused) } - head := cursor + outputGlyph(r.out) + " " + titleStyle.Render(r.out.Title) + stamp := dimStyle.Render(dayOutputTime(r.when, day, r.whenApprox)) + " " + head := cursor + stamp + outputGlyph(r.out) + " " + titleStyle.Render(r.out.Title) anchor := dimStyle.Render(" " + r.shortID) if r.sessions > 1 { @@ -257,6 +450,10 @@ func dayOutputLine(r dayOutputRow, width int, selected bool) string { // the output first appeared, and `o` opens the output itself (a PR/Jira URL) in // the browser. The two are deliberately different questions — "how did this // happen" vs "take me to the thing" — so they are no longer aliases. +// +// tab/shift+tab are NOT handled here: km.Session.Preview consumes them long +// before the focused-preview handlers run, so kind switching lives in that case +// instead (see handleSessionKeys). func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cmd, bool) { switch key { case "enter": @@ -272,14 +469,7 @@ func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cm switch HandleFlatCursorNav(&a.dayOutputsCursor, len(a.dayOutputRows), key) { case NavCursorMoved: a.sessSplit.CacheKey = "" // force the day pane to re-render with the new highlight - // Re-render whichever scope owns the pane. A day-scoped PROJECT row owns - // it too (selectedOwnsDayPane), and rendering only the day case left the - // highlight frozen on those rows. - if di, ok := a.selectedDay(); ok { - a.updateDayPreview(di) - } else if pi, ok := a.selectedProject(); ok && pi.dayKey != "" { - a.updateDayProjectPreview(pi) - } + a.renderOwningDayScope() // Nudge the viewport so the cursor stays in view as it walks past the // fold (the tasks/agents preview does the same). switch key { @@ -298,6 +488,52 @@ func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cm return a, nil, false } +// cycleDayOutputTab moves the day pane's kind filter by delta, wrapping. Only +// the tabs the scope actually has are in the ring, so the cycle never lands on +// an always-empty kind. +// +// The cursor goes back to the top: every row action (Enter, o, y, x) resolves +// through dayOutputsCursor into the FILTERED slice, so keeping an index across +// a tab switch would point at a different output than the highlighted one. +func (a *App) cycleDayOutputTab(delta int) { + tabs := dayOutputTabsFor(a.currentDayOutputRows(), a.dayOutputTabKind) + if len(tabs) <= 1 { + return + } + idx := dayOutputTabIndex(tabs, a.dayOutputTabKind) + a.dayOutputTabKind = tabs[(idx+delta+len(tabs))%len(tabs)].kind + a.dayOutputsCursor = 0 + a.sessSplit.CacheKey = "" + a.renderOwningDayScope() + a.sessSplit.Preview.GotoTop() +} + +// currentDayOutputRows rebuilds the UNFILTERED rows for whichever scope owns the +// pane. The tab bar needs every kind the scope produced, which the filtered +// a.dayOutputRows no longer knows. +func (a *App) currentDayOutputRows() []dayOutputRow { + if di, ok := a.selectedDay(); ok { + return buildDayOutputRows(di) + } + if pi, ok := a.selectedProject(); ok && pi.dayKey != "" { + return buildDayOutputRows(dayItem{sessions: pi.sessions}) + } + return nil +} + +// renderOwningDayScope re-renders whichever scope owns the day pane. A +// day-scoped PROJECT row owns it too (selectedOwnsDayPane), and rendering only +// the day case left the pane frozen on those rows. +func (a *App) renderOwningDayScope() { + if di, ok := a.selectedDay(); ok { + a.updateDayPreview(di) + return + } + if pi, ok := a.selectedProject(); ok && pi.dayKey != "" { + a.updateDayProjectPreview(pi) + } +} + func (a *App) selectedDayOutput() (dayOutputRow, bool) { if a.dayOutputsCursor < 0 || a.dayOutputsCursor >= len(a.dayOutputRows) { return dayOutputRow{}, false diff --git a/internal/tui/daypane_test.go b/internal/tui/daypane_test.go index af0736c..18feb2d 100644 --- a/internal/tui/daypane_test.go +++ b/internal/tui/daypane_test.go @@ -244,9 +244,15 @@ func TestDayPreviewHintsMatchTheFocusedKeys(t *testing.T) { app.sessSplit.Focus = false app.sessSplit.CacheKey = "" _ = app.updateSessionPreview() - if unfocused := app.sessSplit.Preview.View(); !strings.Contains(unfocused, "folds this row") { + unfocused := app.sessSplit.Preview.View() + if !strings.Contains(unfocused, "folds this row") { t.Errorf("unfocused pane should still describe the list's Enter, got:\n%s", unfocused) } + // Same trap, the sort key: unfocused, `s` opens the state menu, so offering + // it here would point at a different action. + if strings.Contains(unfocused, "s: group by kind") { + t.Errorf("unfocused pane advertises the sort key it does not own:\n%s", unfocused) + } } // TestDayPreviewCopyFallsBackToPath mirrors the per-session digest: `y` copies @@ -281,6 +287,253 @@ func TestDayPreviewKeysIgnoreUnrelatedKeys(t *testing.T) { } } +// --- Produced ordering --- + +// timedRef builds a PR ref that first appeared at ts, which is what the day +// pane's timeline sorts on. +func timedRef(label string, ts time.Time) session.SessionRef { + return session.SessionRef{ + Kind: session.RefPR, Label: label, + URL: "https://github.com/sendbird/ccx/pull/" + label, + Resolved: true, + FirstSeen: ts, FirstSeenUUID: "u-" + label, + } +} + +// TestDayOutputRowsFollowFirstAppearance is the point of the timeline: rows come +// out in the order the outputs first appeared, NOT in the order the sessions +// happen to carry them. A session's Refs are sorted first-seen DESCENDING, so +// without the explicit sort the pane reads backwards inside every session. +func TestDayOutputRowsFollowFirstAppearance(t *testing.T) { + day := dayOf(0) + sessions := []session.Session{ + // One session holding two refs — stored newest-first, as SortRefs leaves them. + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: day, + Refs: []session.SessionRef{ + timedRef("late", day.Add(4*time.Hour)), + timedRef("early", day.Add(time.Hour)), + }}, + // A second session whose output lands between the two above, so kind + // grouping alone could not produce the right answer either. + {ID: "b1", ShortID: "b1", ProjectPath: "/tmp/repo-b", ProjectName: "repo-b", ModTime: day.Add(-time.Hour), + Refs: []session.SessionRef{timedRef("middle", day.Add(2*time.Hour))}}, + } + di := buildDailyItems(sessions, nil)[0].(dayItem) + rows := buildDayOutputRows(di) + + var got []string + for _, r := range rows { + got = append(got, r.out.Title) + } + want := []string{"early", "middle", "late"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("timeline order = %v, want %v", got, want) + } +} + +// TestDayOutputTabsKeepOneChronology guards the tab split: filtering by kind +// must not re-order anything — every tab is the same timeline, narrowed. +func TestDayOutputTabsKeepOneChronology(t *testing.T) { + day := dayOf(0) + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ModTime: day, + PlanSlugs: []string{"a-plan"}, + Refs: []session.SessionRef{ + timedRef("late", day.Add(4*time.Hour)), + timedRef("early", day.Add(time.Hour)), + }, + }} + di := buildDailyItems(sessions, nil)[0].(dayItem) + rows := buildDayOutputRows(di) + + prs := filterDayOutputRows(rows, dayOutputTab{label: "PRs", kind: session.OutputPR}) + if len(prs) != 2 { + t.Fatalf("PR tab = %d rows, want 2", len(prs)) + } + if prs[0].out.Title != "early" || prs[1].out.Title != "late" { + t.Errorf("PR tab = %q,%q — want the same chronology the All tab has", prs[0].out.Title, prs[1].out.Title) + } + if all := filterDayOutputRows(rows, dayOutputTabAll); len(all) != 3 { + t.Errorf("All tab = %d rows, want every kind (3)", len(all)) + } +} + +// TestDayOutputRowsFallBackToSessionTime covers refs extracted before FirstSeen +// was recorded, and plan slugs, which carry no entry at all. A zero timestamp +// would sink them to the end of the day and read as "produced last"; the +// producing session's own time is the honest approximation. +func TestDayOutputRowsFallBackToSessionTime(t *testing.T) { + day := dayOf(0) + untimed := session.SessionRef{ + Kind: session.RefPR, Label: "untimed", + URL: "https://github.com/sendbird/ccx/pull/9", Resolved: true, + } + sessions := []session.Session{ + {ID: "old", ShortID: "old", ProjectPath: "/tmp/repo-a", ModTime: day.Add(-5 * time.Hour), + Refs: []session.SessionRef{untimed}}, + {ID: "new", ShortID: "new", ProjectPath: "/tmp/repo-b", ModTime: day, + Refs: []session.SessionRef{timedRef("timed", day.Add(-time.Hour))}}, + } + di := buildDailyItems(sessions, nil)[0].(dayItem) + rows := buildDayOutputRows(di) + + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0].out.Title != "untimed" { + t.Errorf("first row = %q, want the untimed ref placed at its session's time (%s), not sunk to the bottom", + rows[0].out.Title, day.Add(-5*time.Hour).Format("15:04")) + } + if rows[0].when.IsZero() { + t.Error("untimed row kept a zero time — it has nothing to render in the timeline") + } +} + +// TestDayPreviewTabKeySwitchesKind drives the REAL key path: tab is consumed by +// km.Session.Preview long before the focused-preview handlers run, so the day +// pane's kind switch has to live in that case. Driving handleDayPreviewKeys +// directly would pass while the actual keypress does nothing. +func TestDayPreviewTabKeySwitchesKind(t *testing.T) { + day := dayOf(0) + // Three PRs and one plan: the PR tab is LONGER than the cursor index used + // below, so a missing reset cannot hide behind the render's range clamp — it + // would leave the cursor on a real but different PR. + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: day, + PlanSlugs: []string{"a-plan"}, + Refs: []session.SessionRef{ + timedRef("pr-1", day.Add(time.Hour)), + timedRef("pr-2", day.Add(2*time.Hour)), + timedRef("pr-3", day.Add(3*time.Hour)), + }, + }} + app := dayPaneApp(t, sessions) + + if app.dayOutputTabKind != "" { + t.Fatalf("default tab = %q, want the All timeline", app.dayOutputTabKind) + } + if len(app.dayOutputRows) != 4 { + t.Fatalf("All tab = %d rows, want 4", len(app.dayOutputRows)) + } + if view := app.sessSplit.Preview.View(); !strings.Contains(view, "[All 4]") { + t.Errorf("tab bar missing the active All tab with its count:\n%s", view) + } + + // A stale cursor across a tab switch would act on a different output than the + // highlighted one, so it must reset. + app.dayOutputsCursor = 2 + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyTab}) + app = m.(*App) + + if app.dayOutputTabKind != session.OutputPR { + t.Fatalf("tab after one press = %q, want the PR tab", app.dayOutputTabKind) + } + if app.dayOutputsCursor != 0 { + t.Errorf("cursor = %d, want 0 — a stale index acts on a different PR than the one highlighted", app.dayOutputsCursor) + } + if len(app.dayOutputRows) != 3 { + t.Fatalf("PR tab = %d actionable rows, want 3 — the cursor indexes THIS slice", len(app.dayOutputRows)) + } + for _, r := range app.dayOutputRows { + if r.out.Kind != session.OutputPR { + t.Errorf("PR tab leaked a %s row", r.out.Kind) + } + } + if view := app.sessSplit.Preview.View(); strings.Contains(view, "a-plan") { + t.Errorf("PR tab still renders the plan row:\n%s", view) + } + + // shift+tab walks back. + m, _ = app.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + app = m.(*App) + if app.dayOutputTabKind != "" { + t.Errorf("tab after shift+tab = %q, want All back", app.dayOutputTabKind) + } +} + +// TestDayPreviewTabSurvivesTheCacheKey pins the repaint path. cycleDayOutputTab +// clears CacheKey and renders, but the very next updateSessionPreview() (any +// navigation, any refresh tick) recomputes the key — if the tab is not part of +// it, that call sees a "matching" key, returns early, and the pane silently +// reverts to whatever was rendered under the old tab. +func TestDayPreviewTabSurvivesTheCacheKey(t *testing.T) { + day := dayOf(0) + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: day, + PlanSlugs: []string{"a-plan"}, + Refs: []session.SessionRef{timedRef("pr-1", day.Add(time.Hour))}, + }} + app := dayPaneApp(t, sessions) + + // Render once under All so a stale key would have All's content behind it. + if len(app.dayOutputRows) != 2 { + t.Fatalf("All tab = %d rows, want 2", len(app.dayOutputRows)) + } + keyAll := app.sessSplit.CacheKey + + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyTab}) + app = m.(*App) + if app.dayOutputTabKind != session.OutputPR { + t.Fatalf("tab = %q, want the PR tab", app.dayOutputTabKind) + } + + // The next preview pass must NOT think it is already up to date. + _ = app.updateSessionPreview() + if app.sessSplit.CacheKey == keyAll { + t.Fatalf("cache key is unchanged across a tab switch (%q) — the pane will not repaint", keyAll) + } + if len(app.dayOutputRows) != 1 { + t.Errorf("after the refresh the pane holds %d rows, want the PR tab's 1 — it reverted to All", len(app.dayOutputRows)) + } + if view := app.sessSplit.Preview.View(); strings.Contains(view, "a-plan") { + t.Errorf("pane reverted to the All tab's content:\n%s", view) + } +} + +// TestDayPreviewTabDoesNotTouchSessionPreviewMode pins the other half of that +// interception: on a day row, tab must NOT rotate sessPreviewMode. It used to, +// silently — the pane ignores the mode, so the rotation was invisible state +// corruption that surfaced only after moving to a session row. +func TestDayPreviewTabDoesNotTouchSessionPreviewMode(t *testing.T) { + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", + ModTime: dayOf(0), + Refs: []session.SessionRef{timedRef("pr-1", dayOf(0))}, + }} + app := dayPaneApp(t, sessions) + before := app.sessPreviewMode + + m, _ := app.Update(tea.KeyMsg{Type: tea.KeyTab}) + if got := m.(*App).sessPreviewMode; got != before { + t.Errorf("sessPreviewMode = %v, want it untouched at %v — a day row has no preview modes", got, before) + } +} + +// TestDayPreviewTabStaysStickyOnEmptyKind covers walking dates under a filter: +// a day that produced nothing of the selected kind shows an empty state under +// that same tab. Falling back to All would break the date-to-date comparison +// the sticky tab exists for. +func TestDayPreviewTabStaysStickyOnEmptyKind(t *testing.T) { + sessions := []session.Session{{ + ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", + ModTime: dayOf(0), PlanSlugs: []string{"a-plan"}, + }} + app := dayPaneApp(t, sessions) + app.dayOutputTabKind = session.OutputPR // as if carried over from another day + app.sessSplit.CacheKey = "" + _ = app.updateSessionPreview() + + if app.dayOutputTabKind != session.OutputPR { + t.Errorf("tab = %q, want it kept so dates stay comparable", app.dayOutputTabKind) + } + if len(app.dayOutputRows) != 0 { + t.Errorf("expected no rows under the PR tab, got %d", len(app.dayOutputRows)) + } + if view := app.sessSplit.Preview.View(); !strings.Contains(view, "nothing of this kind") { + t.Errorf("empty kind tab is missing its own empty state:\n%s", view) + } +} + // selectedConvEntryUUID returns the uuid of the first transcript entry covered // by the conversation row under the cursor. func selectedConvEntryUUID(a *App) string { diff --git a/internal/tui/help.go b/internal/tui/help.go index 5fb9e5d..c69139b 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -79,7 +79,13 @@ func (a *App) sessHelpLine() string { switch { case a.outputsPreviewActionsActive(): // Both digests: the row under the cursor is what the keys act on. - h = "↑↓:nav ↵:jump o:open " + fmtKey(sk.Actions, "actions") + " ←:unfocus p:page" + h = "↑↓:nav ↵:jump o:open " + fmtKey(sk.Actions, "actions") + if a.selectedOwnsDayPane() { + // Only the day pane is tabbed by kind — the per-session digest + // has one fixed list, so advertising it there would mislead. + h += " tab:kind" + } + h += " ←:unfocus p:page" case a.sessPreviewMode == sessPreviewConversation: h = "↑↓:nav c:full " + fmtKey(sk.Open, "jump") + " ←:unfocus tab:mode" case a.sessPreviewMode == sessPreviewAgents: