From ff3614d2ef2ba593ba4fa4dc2d40e9359112eddd Mon Sep 17 00:00:00 2001 From: keyolk Date: Mon, 31 Aug 2026 08:44:34 +0900 Subject: [PATCH] feat: give the daily Produced pane its own search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two panes of the daily view could not be searched independently. The right pane has kind tabs but no text filter, and "/" there took focus AWAY to open the session list's filter: case "/": sp.Focus = false return a, startListSearch(&a.sessionList), true A real day produces hundreds of outputs — 682 in the reported case. Tabs narrow by kind, but "where is that CPLAT ticket" had no answer short of scrolling. Give the pane its own query, applied in the same filterDayOutputRows step as the kind tab. Filtering at render time only would leave Enter/o/y/x acting on a different row than the highlighted one, since dayOutputsCursor indexes the filtered slice. Terms are AND-ed and matched case-insensitively across title, detail, path, URL, kind, project and short ID, so "cplat argocd" narrows without the reader having to know which field holds which part. It composes with the kind tab, and "/" on the session list is unchanged — the two panes answer different questions and a day with hundreds of outputs needs narrowing even when the session list does not. Two details worth stating: - Esc restores the query as of the input OPENING, not the current one. Typing applies live, so by the time Esc arrives dayOutputQuery already holds the edited value; restoring that would let an abandoned edit silently become the filter. A test caught this. - The query resets on a scope change, unlike the sticky kind tab. A kind filter is a lens you carry across dates; a text query is about one day's specific rows, and carrying it would hide the next day's outputs behind a filter the user has stopped thinking about. The heading states the narrowing ("Produced (3) of 682 /cplat") so a filtered count is not read as a quiet day, and an empty result under a query says "nothing matching" rather than "nothing produced" — those are different answers. --- README.md | 2 + internal/tui/app.go | 35 +++- internal/tui/daily_test.go | 2 +- internal/tui/daypane.go | 125 +++++++++++++-- internal/tui/daypane_search_test.go | 241 ++++++++++++++++++++++++++++ internal/tui/daypane_test.go | 4 +- internal/tui/help.go | 3 + 7 files changed, 392 insertions(+), 20 deletions(-) create mode 100644 internal/tui/daypane_search_test.go diff --git a/README.md b/README.md index 560d9bd..fa18500 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,8 @@ To read one kind at a time, the pane is **tabbed**: `All` plus a tab for each ki 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. +The pane also has **its own search**: with the preview focused, `/` filters the day's outputs by text (title, detail, path, URL, kind, project), AND-ing terms so `cplat argocd` narrows without you having to know which field holds which part. It composes with the kind tab, and the heading says the count is filtered (`Produced (3) of 682 /cplat`) so a narrowed list is never mistaken for a quiet day. This is deliberately **separate from the session list's `/`** — the two panes answer different questions ("which sessions" vs "which outputs"), and a day with hundreds of outputs needs narrowing even when the session list does not. `Esc` clears it; unlike the kind tab, the query does not travel across dates. + 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. **Known limitation — produced vs. referenced.** A reference counts as an output if the session's transcript contains its URL, which includes links that were merely read or quoted (a `kubernetes/kubernetes` PR consulted during debugging shows up next to the PR the session actually opened). Artifacts already avoid this — they are only counted from the `Published … at ` tool result — but PRs and Jira issues have no equivalent creation marker yet. Treat the Produced list as "references this day touched", weighted toward what it created. diff --git a/internal/tui/app.go b/internal/tui/app.go index 106e0c3..01da58e 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -324,10 +324,17 @@ type App struct { 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 - openURL func(string) error // opens a URL in the browser; overridable in tests (defaults to `open`) + // The day pane searches independently of the session list: the two answer + // different questions ("which sessions" vs "which outputs"), and a day with + // hundreds of outputs needs narrowing even when the session list does not. + dayOutputSearching bool // typing in the day pane's search input + dayOutputSearchTI textinput.Model // day pane search input + dayOutputQuery string // applied day pane query ("" = no filter) + dayOutputQueryBefore string // query as of the input opening, restored on Esc + 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 + openURL func(string) error // opens a URL in the browser; overridable in tests (defaults to `open`) // Conversation preview state sessConvEntries []mergedMsg // merged conversation messages @@ -1558,6 +1565,15 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } + // The day pane's own query unwinds the same way: while that pane is + // focused, Esc drops its filter before it means anything else, so the + // narrowing you can see is the thing Esc undoes. + if msg.String() == "esc" && a.state == viewSessions && a.sessSplit.Focus && + a.selectedOwnsDayPane() && a.dayOutputQuery != "" { + a.clearDayOutputSearch() + return a, nil + } + // Esc clears an applied search filter before doing normal navigation. // In the session list we only clear via esc while the "/" search input // is active (isFiltering, handled above) — an applied filter (e.g. the @@ -2039,6 +2055,11 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a.handleConvSearch(msg) } + // Same for the day pane's own output search. + if a.dayOutputSearching { + return a.handleDayOutputSearch(msg) + } + // Move mode: text input for new project path if a.moveMode { return a.handleMoveInput(msg) @@ -5470,7 +5491,7 @@ func (a *App) updateSessionPreview() tea.Cmd { // The pane is the day's outputs regardless of preview mode, so the mode // 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) + cacheKey := fmt.Sprintf("day:%s:%d:%d:%t:%s:%s", di.dayKey, len(di.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind, a.dayOutputQuery) if cacheKey == a.sessSplit.CacheKey { return nil } @@ -5484,8 +5505,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:%s", pi.dayKey, pi.basePath, - len(pi.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind) + cacheKey := fmt.Sprintf("dayproj:%s:%s:%d:%d:%t:%s:%s", pi.dayKey, pi.basePath, + len(pi.sessions), a.dayOutputsCursor, a.sessSplit.Focus, a.dayOutputTabKind, a.dayOutputQuery) if cacheKey == a.sessSplit.CacheKey { return nil } diff --git a/internal/tui/daily_test.go b/internal/tui/daily_test.go index cabfe5c..e272f7b 100644 --- a/internal/tui/daily_test.go +++ b/internal/tui/daily_test.go @@ -565,7 +565,7 @@ func TestDayPreviewTabsCoverEveryKindProduced(t *testing.T) { if tb.kind == "" { continue } - for _, r := range filterDayOutputRows(rows, tb) { + 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 1a130c7..bf9351e 100644 --- a/internal/tui/daypane.go +++ b/internal/tui/daypane.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -89,19 +90,49 @@ func dayOutputTabsFor(rows []dayOutputRow, active session.OutputKind) []dayOutpu 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 +// dayOutputRowMatches reports whether a row matches every term in the query. +// Terms are AND-ed and matched case-insensitively against everything visible on +// the row plus its target, so "cplat argocd" narrows the way a reader expects +// without needing to know which field holds which part. +func dayOutputRowMatches(r dayOutputRow, terms []string) bool { + if len(terms) == 0 { + return true + } + hay := strings.ToLower(strings.Join([]string{ + r.out.Title, r.out.Detail, r.out.Path, r.out.URL, + string(r.out.Kind), r.project, r.shortID, + }, "\x00")) + for _, t := range terms { + if !strings.Contains(hay, t) { + return false + } + } + return true +} + +// filterDayOutputRows narrows rows to one tab's kind and the pane's own search +// query. 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 == "" { +// +// The query is the day pane's own, independent of the session list's filter: +// the two panes answer different questions ("which sessions" vs "which +// outputs"), and a day with 682 outputs needs narrowing even when the session +// list does not. +func filterDayOutputRows(rows []dayOutputRow, tab dayOutputTab, query string) []dayOutputRow { + terms := strings.Fields(strings.ToLower(strings.TrimSpace(query))) + if tab.kind == "" && len(terms) == 0 { return rows } out := make([]dayOutputRow, 0, len(rows)) for _, r := range rows { - if r.out.Kind == tab.kind { - out = append(out, r) + if tab.kind != "" && r.out.Kind != tab.kind { + continue + } + if !dayOutputRowMatches(r, terms) { + continue } + out = append(out, r) } return out } @@ -238,6 +269,11 @@ func (a *App) updateDayPreview(di dayItem) { if a.dayOutputsCacheID != di.dayKey { a.dayOutputsCursor = 0 a.dayOutputsCacheID = di.dayKey + // The query is dropped on a scope change, unlike the TAB. A kind filter + // is a lens you carry across dates; a text query is about one day's + // specific rows, and carrying it would silently hide the new day's + // outputs behind a filter the user is no longer thinking about. + a.dayOutputQuery = "" } all := buildDayOutputRows(di) @@ -266,6 +302,7 @@ func (a *App) updateDayProjectPreview(pi projectItem) { if a.dayOutputsCacheID != cacheID { a.dayOutputsCursor = 0 a.dayOutputsCacheID = cacheID + a.dayOutputQuery = "" // see updateDayPreview: queries do not travel } all := buildDayOutputRows(dayItem{sessions: pi.sessions}) @@ -308,7 +345,7 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, day time.Time, tabs := dayOutputTabsFor(all, a.dayOutputTabKind) active := tabs[dayOutputTabIndex(tabs, a.dayOutputTabKind)] - rows := filterDayOutputRows(all, active) + rows := filterDayOutputRows(all, active, a.dayOutputQuery) if a.dayOutputsCursor >= len(rows) { a.dayOutputsCursor = 0 } @@ -325,12 +362,19 @@ func (a *App) renderOutputsPane(title, subtitle, summary string, day time.Time, sb.WriteString(a.renderDayOutputTabs(tabs, active, all) + "\n") heading := fmt.Sprintf("Produced (%d)", len(rows)) + if a.dayOutputQuery != "" { + // Say the count is filtered and by what. Without this a narrowed list + // reads as "this day produced 3 things", which is a different claim. + heading += fmt.Sprintf(" of %d /%s", len(all), a.dayOutputQuery) + } if len(rows) > 0 && a.sessSplit.Focus { - heading += " ↵:jump to first mention o:open y:copy x:actions" + heading += " ↵:jump to first mention o:open y:copy x:actions /:search" } sb.WriteString(section.Render(heading) + "\n") switch { + case len(rows) == 0 && a.dayOutputQuery != "": + sb.WriteString(dimStyle.Render(fmt.Sprintf(" nothing matching %q", a.dayOutputQuery)) + "\n\n") 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 @@ -463,8 +507,12 @@ func (a *App) handleDayPreviewKeys(sp *SplitPane, key string) (tea.Model, tea.Cm case a.keymap.Actions.CopyPath, "y": return a.copySelectedDayOutput() case "/": - sp.Focus = false - return a, startListSearch(&a.sessionList), true + // Search the pane you are in. Focus stays here: the day pane has its own + // query because "which outputs" and "which sessions" are different + // questions, and a day with hundreds of rows needs narrowing on its own + // terms. + a.startDayOutputSearch() + return a, nil, true } switch HandleFlatCursorNav(&a.dayOutputsCursor, len(a.dayOutputRows), key) { case NavCursorMoved: @@ -657,3 +705,60 @@ func chronological(sessions []session.Session) []session.Session { sort.SliceStable(out, func(i, j int) bool { return out[i].ModTime.Before(out[j].ModTime) }) return out } + +// startDayOutputSearch opens the day pane's own search input, pre-filled with +// the applied query so refining is editing rather than retyping. +func (a *App) startDayOutputSearch() { + a.dayOutputSearching = true + // Remember what was applied when the input opened. Typing applies live, so + // by the time Esc arrives dayOutputQuery already holds the edited value and + // is no longer what "cancel" should restore. + a.dayOutputQueryBefore = a.dayOutputQuery + ti := textinput.New() + ti.Prompt = "Search outputs: " + ti.SetValue(a.dayOutputQuery) + ti.CursorEnd() + ti.Focus() + a.dayOutputSearchTI = ti +} + +// handleDayOutputSearch processes keys while the day pane's search is active. +// The query applies as you type so the row count reacts immediately; Esc +// restores whatever was applied when the input opened. +func (a *App) handleDayOutputSearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "enter": + a.dayOutputSearching = false + a.applyDayOutputQuery(a.dayOutputSearchTI.Value()) + return a, nil + case "esc": + a.dayOutputSearching = false + // Esc cancels the edit, not the filter: it restores what was applied when + // the input opened, so an abandoned edit does not silently become the + // filter and an accidental keypress does not lose the narrowing. + a.applyDayOutputQuery(a.dayOutputQueryBefore) + return a, nil + } + var cmd tea.Cmd + a.dayOutputSearchTI, cmd = a.dayOutputSearchTI.Update(msg) + a.applyDayOutputQuery(a.dayOutputSearchTI.Value()) + return a, cmd +} + +// applyDayOutputQuery sets the pane's query and re-renders. The cursor goes back +// to the top for the same reason a tab switch resets it: every row action +// resolves through dayOutputsCursor into the FILTERED slice, so an index kept +// across a filter change would point at a different output than the highlighted +// one. +func (a *App) applyDayOutputQuery(q string) { + a.dayOutputQuery = q + a.dayOutputsCursor = 0 + a.sessSplit.CacheKey = "" + a.renderOwningDayScope() +} + +// clearDayOutputSearch drops the pane's query entirely. +func (a *App) clearDayOutputSearch() { + a.dayOutputSearching = false + a.applyDayOutputQuery("") +} diff --git a/internal/tui/daypane_search_test.go b/internal/tui/daypane_search_test.go new file mode 100644 index 0000000..e85a7d1 --- /dev/null +++ b/internal/tui/daypane_search_test.go @@ -0,0 +1,241 @@ +package tui + +import ( + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +func outRow(kind session.OutputKind, title, detail, project string) dayOutputRow { + return dayOutputRow{ + out: session.SessionOutput{Kind: kind, Title: title, Detail: detail}, + project: project, + shortID: "abc1234", + when: time.Now(), + } +} + +func rowTitles(rows []dayOutputRow) []string { + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.out.Title) + } + return out +} + +func TestDayOutputQueryFiltersRows(t *testing.T) { + rows := []dayOutputRow{ + outRow(session.OutputPR, "sendbird/ops-k8s#42", "argocd chart bump", "ops-k8s"), + outRow(session.OutputJira, "CPLAT-11790", "search resume", "ccx"), + outRow(session.OutputPR, "sendbird/ccx#162", "live state in search", "ccx"), + } + + cases := []struct { + query string + want []string + }{ + {"", []string{"sendbird/ops-k8s#42", "CPLAT-11790", "sendbird/ccx#162"}}, + {"cplat", []string{"CPLAT-11790"}}, + {"CPLAT", []string{"CPLAT-11790"}}, // case-insensitive + {"search", []string{"CPLAT-11790", "sendbird/ccx#162"}}, + {"ccx search", []string{"CPLAT-11790", "sendbird/ccx#162"}}, // AND across fields + {"argocd", []string{"sendbird/ops-k8s#42"}}, // matches Detail + {"ops-k8s", []string{"sendbird/ops-k8s#42"}}, // matches project + {"nothingmatches", nil}, + } + for _, c := range cases { + got := rowTitles(filterDayOutputRows(rows, dayOutputTabAll, c.query)) + if len(got) != len(c.want) { + t.Errorf("query %q: got %v, want %v", c.query, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("query %q: got %v, want %v", c.query, got, c.want) + break + } + } + } +} + +// The kind tab and the text query are independent narrowings and must compose. +func TestDayOutputQueryComposesWithKindTab(t *testing.T) { + rows := []dayOutputRow{ + outRow(session.OutputPR, "pr-search", "", "ccx"), + outRow(session.OutputJira, "jira-search", "", "ccx"), + outRow(session.OutputPR, "pr-other", "", "ccx"), + } + prTab := dayOutputTab{label: "PRs", kind: session.OutputPR} + + got := rowTitles(filterDayOutputRows(rows, prTab, "search")) + if len(got) != 1 || got[0] != "pr-search" { + t.Errorf("kind+query = %v, want [pr-search]", got) + } +} + +// daySearchApp reuses the daily-view harness and stocks the pane with rows to +// filter (dayPaneApp lives in daypane_test.go). +func daySearchApp(t *testing.T) *App { + t.Helper() + a := dayPaneApp(t, fakeSessions()) + a.dayOutputRows = []dayOutputRow{ + outRow(session.OutputPR, "pr-one", "", "p"), + outRow(session.OutputJira, "CPLAT-1", "", "p"), + } + return a +} + +// "/" in the day pane must search the day pane, not steal focus to the session +// list — the two panes answer different questions. +func TestSlashInDayPaneOpensItsOwnSearch(t *testing.T) { + a := daySearchApp(t) + sp := &a.sessSplit + + _, _, handled := a.handleDayPreviewKeys(sp, "/") + if !handled { + t.Fatal("day pane did not handle /") + } + if !a.dayOutputSearching { + t.Error("/ did not open the day pane's own search") + } + if !sp.Focus { + t.Error("/ moved focus away from the day pane") + } +} + +func TestDayOutputSearchAppliesAsYouType(t *testing.T) { + a := daySearchApp(t) + a.startDayOutputSearch() + + for _, r := range "cplat" { + m, _ := a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + a = m.(*App) + } + if a.dayOutputQuery != "cplat" { + t.Errorf("query = %q, want %q", a.dayOutputQuery, "cplat") + } + + m, _ := a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyEnter}) + a = m.(*App) + if a.dayOutputSearching { + t.Error("enter did not close the search input") + } + if a.dayOutputQuery != "cplat" { + t.Errorf("query after enter = %q, want %q", a.dayOutputQuery, "cplat") + } +} + +// Esc cancels the edit, not the filter — otherwise a stray keypress loses the +// narrowing the user built up. +func TestDayOutputSearchEscKeepsAppliedQuery(t *testing.T) { + a := daySearchApp(t) + a.applyDayOutputQuery("cplat") + a.startDayOutputSearch() + + m, _ := a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + a = m.(*App) + m, _ = a.handleDayOutputSearch(tea.KeyMsg{Type: tea.KeyEsc}) + a = m.(*App) + + if a.dayOutputSearching { + t.Error("esc did not close the input") + } + if a.dayOutputQuery != "cplat" { + t.Errorf("esc dropped the applied query: %q, want %q", a.dayOutputQuery, "cplat") + } +} + +// Changing the filter must reset the cursor: every row action resolves through +// dayOutputsCursor into the FILTERED slice, so a kept index would act on a +// different output than the highlighted one. +func TestDayOutputQueryResetsCursor(t *testing.T) { + a := daySearchApp(t) + a.dayOutputsCursor = 1 + a.applyDayOutputQuery("cplat") + if a.dayOutputsCursor != 0 { + t.Errorf("cursor = %d after filter change, want 0", a.dayOutputsCursor) + } +} + +// The heading must say the count is filtered, or a narrowed list reads as +// "this day produced 3 things". +func TestDayPaneHeadingShowsQuery(t *testing.T) { + a := daySearchApp(t) + a.width, a.height = 200, 50 + all := []dayOutputRow{ + outRow(session.OutputPR, "pr-one", "", "p"), + outRow(session.OutputJira, "CPLAT-1", "", "p"), + } + a.dayOutputQuery = "cplat" + + view := stripANSI(a.renderOutputsPane("Today", "", "1 session", time.Now(), all, 120)) + if !strings.Contains(view, "of 2") { + t.Errorf("heading does not show the unfiltered total:\n%s", view) + } + if !strings.Contains(view, "cplat") { + t.Errorf("heading does not show the active query:\n%s", view) + } +} + +// An empty result under a query is a different answer than an empty day. +func TestDayPaneEmptyQueryResultIsDistinct(t *testing.T) { + a := daySearchApp(t) + a.width, a.height = 200, 50 + all := []dayOutputRow{outRow(session.OutputPR, "pr-one", "", "p")} + a.dayOutputQuery = "zzzznotfound" + + view := stripANSI(a.renderOutputsPane("Today", "", "1 session", time.Now(), all, 120)) + if !strings.Contains(view, "nothing matching") { + t.Errorf("empty query result is not distinguished from an empty day:\n%s", view) + } +} + +// The left pane keeps its own search: "/" with the list focused must still open +// the session filter, not the day pane's. +func TestSlashInSessionListStillFiltersSessions(t *testing.T) { + a := daySearchApp(t) + a.sessSplit.Focus = false // focus on the list, not the pane + + m, _ := a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + got := m.(*App) + + if got.dayOutputSearching { + t.Error("/ on the session list opened the day pane's search") + } + if !got.isFiltering() { + t.Error("/ on the session list did not open the session filter") + } +} + +// The two queries are independent: filtering one pane must not touch the other. +func TestPaneSearchesAreIndependent(t *testing.T) { + a := daySearchApp(t) + a.applyDayOutputQuery("cplat") + + if a.sessionList.FilterInput.Value() != "" { + t.Error("day pane query leaked into the session list filter") + } + + a.sessionList.SetFilterText("proj-a") + if a.dayOutputQuery != "cplat" { + t.Errorf("session filter clobbered the day pane query: %q", a.dayOutputQuery) + } +} + +// Moving to a different day must drop the query — carrying it would silently +// hide the new day's outputs behind a filter the user is no longer thinking of. +func TestDayOutputQueryResetsOnScopeChange(t *testing.T) { + a := daySearchApp(t) + a.applyDayOutputQuery("cplat") + a.dayOutputsCacheID = "some-other-day" + + di := dayItem{dayKey: "2026-08-31", sessions: fakeSessions()} + a.updateDayPreview(di) + + if a.dayOutputQuery != "" { + t.Errorf("query survived a day change: %q", a.dayOutputQuery) + } +} diff --git a/internal/tui/daypane_test.go b/internal/tui/daypane_test.go index 195158b..ae0e1b1 100644 --- a/internal/tui/daypane_test.go +++ b/internal/tui/daypane_test.go @@ -347,14 +347,14 @@ func TestDayOutputTabsKeepOneChronology(t *testing.T) { di := buildDailyItems(sessions, nil)[0].(dayItem) rows := buildDayOutputRows(di) - prs := filterDayOutputRows(rows, dayOutputTab{label: "PRs", kind: session.OutputPR}) + 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 { + if all := filterDayOutputRows(rows, dayOutputTabAll, ""); len(all) != 3 { t.Errorf("All tab = %d rows, want every kind (3)", len(all)) } } diff --git a/internal/tui/help.go b/internal/tui/help.go index 0d7d6e9..9538f59 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -61,6 +61,9 @@ func (a *App) sessHelpLine() string { if a.sessConvSearching { return " " + a.sessConvSearchInput.View() + helpStyle.Render(" enter:apply esc:cancel") } + if a.dayOutputSearching { + return " " + a.dayOutputSearchTI.View() + helpStyle.Render(" enter:apply esc:cancel") + } // Pane proxy (live preview) if a.sessSplit.Focus && a.paneProxy != nil && a.sessPreviewMode == sessPreviewLive {