From 8e2ea2341ec414ae2b6fac2b2a1bd9db1374231a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 17:38:53 +0000 Subject: [PATCH] tui: fix status/lead edits not saving, hide archived by default startEditStatus and startEditLead never copied the target's base revision into the model, so a save picked up whatever revision was left over from the last priority/next-action edit. Against the real store that stale revision triggers a false concurrency conflict, silently rejecting the status or lead change instead of applying it. Also default the dashboard to hiding resolved/archived dossiers (they were cluttering the view), and turn the lead landing screen ('f') into a general filter picker: it now includes a pinned row to show/hide resolved & archived dossiers alongside the existing lead scoping. --- internal/tui/tui.go | 114 +++++++++++++++++++++++----------- internal/tui/tui_test.go | 129 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 203 insertions(+), 40 deletions(-) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 97a12d2..7800f04 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -85,9 +85,12 @@ func (f leadFilter) label() string { // leadOption is one selectable row in the lead landing screen: a filter plus the // number of dossiers it would show, computed once when the option list is built. +// isArchivedToggle marks the single pinned row (always last) that flips whether +// resolved/archived dossiers are hidden, rather than scoping by lead. type leadOption struct { - filter leadFilter - count int + filter leadFilter + count int + isArchivedToggle bool } // Styling tokens @@ -215,11 +218,12 @@ type Model struct { recallResult core.RecallResult // Lead landing screen state - leadFilter leadFilter // active dashboard scope (defaults to All) - leadSearch textinput.Model // search-as-you-type box - leadOptions []leadOption // every selectable lead, counts included - leadResults []leadOption // leadOptions narrowed by the search box - leadCursor int + leadFilter leadFilter // active dashboard scope (defaults to All) + leadSearch textinput.Model // search-as-you-type box + leadOptions []leadOption // every selectable lead, counts included + leadResults []leadOption // leadOptions narrowed by the search box + leadCursor int + hideResolvedArchived bool // when true (the default), resolved/archived dossiers are hidden from the dashboard // Viewport & Table table table.Model @@ -346,17 +350,18 @@ func NewModel(svc *core.Service) Model { } return Model{ - svc: svc, - currentView: ViewLeadSelector, - table: t, - viewport: vp, - conflictViewport: cvp, - loading: true, - statusOptions: statusOptions, - leadSearch: leadSearch, - watcher: watcher, - updateChan: updateChan, - watchedPaths: map[string]bool{}, + svc: svc, + currentView: ViewLeadSelector, + table: t, + viewport: vp, + conflictViewport: cvp, + loading: true, + statusOptions: statusOptions, + leadSearch: leadSearch, + hideResolvedArchived: true, + watcher: watcher, + updateChan: updateChan, + watchedPaths: map[string]bool{}, } } @@ -561,14 +566,23 @@ func (m Model) getTargetDossier() (targetDossier, bool) { // deriveLeadOptions builds the lead landing screen's rows from the full dossier // list: "All" and "Unassigned" pinned first, then each distinct lead in // case-insensitive alphabetical order, every row annotated with its dossier -// count. Pure: depends only on items. -func deriveLeadOptions(items []core.ListItem) []leadOption { - var all, unassigned int +// count, and finally the archived/resolved visibility toggle. When +// hideResolvedArchived is true, resolved and archived dossiers are excluded from +// every count above the toggle so the counts match what the dashboard will +// actually show. Pure: depends only on items and the flag. +func deriveLeadOptions(items []core.ListItem, hideResolvedArchived bool) []leadOption { + var all, unassigned, hidden int counts := make(map[string]int) for _, item := range items { if item.ID == "" { continue // skip placeholder/header rows } + if statusTier(item.Status) == 1 { + hidden++ + if hideResolvedArchived { + continue + } + } all++ if item.Lead == "" { unassigned++ @@ -585,7 +599,7 @@ func deriveLeadOptions(items []core.ListItem) []leadOption { return strings.ToLower(names[i]) < strings.ToLower(names[j]) }) - opts := make([]leadOption, 0, len(names)+2) + opts := make([]leadOption, 0, len(names)+3) opts = append(opts, leadOption{filter: leadFilter{kind: filterAll}, count: all}, leadOption{filter: leadFilter{kind: filterUnassigned}, count: unassigned}, @@ -596,6 +610,7 @@ func deriveLeadOptions(items []core.ListItem) []leadOption { count: counts[name], }) } + opts = append(opts, leadOption{isArchivedToggle: true, count: hidden}) return opts } @@ -620,7 +635,7 @@ func filterLeadOptions(opts []leadOption, query string) []leadOption { } out := make([]leadOption, 0, len(opts)) for _, o := range opts { - if strings.Contains(strings.ToLower(o.filter.label()), query) { + if o.isArchivedToggle || strings.Contains(strings.ToLower(o.filter.label()), query) { out = append(out, o) } } @@ -633,9 +648,13 @@ func filterLeadOptions(opts []leadOption, query string) []leadOption { func (m *Model) applyLeadFilter() { visible := make([]core.ListItem, 0, len(m.items)) for _, item := range m.items { - if m.leadFilter.matches(item) { - visible = append(visible, item) + if !m.leadFilter.matches(item) { + continue } + if m.hideResolvedArchived && statusTier(item.Status) == 1 { + continue + } + visible = append(visible, item) } m.visibleItems = visible } @@ -651,11 +670,11 @@ func (m *Model) openLeadSelector() { m.leadSearch.Focus() m.leadSearch.Width = 40 - m.leadOptions = deriveLeadOptions(m.items) + m.leadOptions = deriveLeadOptions(m.items, m.hideResolvedArchived) m.leadResults = m.leadOptions m.leadCursor = 0 for i, o := range m.leadResults { - if o.filter == m.leadFilter { + if !o.isArchivedToggle && o.filter == m.leadFilter { m.leadCursor = i break } @@ -679,6 +698,7 @@ func (m *Model) startEditStatus(t targetDossier) { m.currentView = ViewStatusPicker m.targetID = t.id m.targetName = t.name + m.targetBaseRevision = t.baseRevision m.statusCursor = 0 for i, o := range m.statusOptions { @@ -707,6 +727,7 @@ func (m *Model) startEditLead(t targetDossier) { m.currentView = ViewLeadEditor m.targetID = t.id m.targetName = t.name + m.targetBaseRevision = t.baseRevision m.leadInput = textinput.New() m.leadInput.Placeholder = "e.g. Alice" @@ -905,6 +926,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case "enter": if len(m.leadResults) > 0 { + if m.leadResults[m.leadCursor].isArchivedToggle { + m.hideResolvedArchived = !m.hideResolvedArchived + m.applyLeadFilter() + m.populateTableRows() + m.currentView = ViewDashboard + m.table.SetCursor(0) + m.table.Focus() + return m, nil + } m.chooseLead() } return m, nil @@ -1217,7 +1247,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Re-derive lead options on every refresh so newly-assigned leads appear, // while preserving the active filter (and the search box) across hot-reloads. - m.leadOptions = deriveLeadOptions(m.items) + m.leadOptions = deriveLeadOptions(m.items, m.hideResolvedArchived) m.leadResults = filterLeadOptions(m.leadOptions, m.leadSearch.Value()) if m.leadCursor >= len(m.leadResults) { m.leadCursor = len(m.leadResults) - 1 @@ -1650,9 +1680,21 @@ func (m Model) renderPriorityEditor() string { return editorBoxStyle.Render(sb.String()) } +// leadOptionLabel is the display text for a lead-selector row: the lead's +// filter label, or the archived/resolved toggle's current-state description. +func (m Model) leadOptionLabel(opt leadOption) string { + if opt.isArchivedToggle { + if m.hideResolvedArchived { + return "Show resolved & archived" + } + return "Hide resolved & archived" + } + return opt.filter.label() +} + func (m Model) renderLeadSelector() string { var sb strings.Builder - sb.WriteString("Filter dossiers by Lead — everything you need before a meeting.\n\n") + sb.WriteString("Filters — scope the dashboard before a meeting.\n\n") sb.WriteString(m.leadSearch.View()) sb.WriteString("\n\n") @@ -1685,7 +1727,7 @@ func (m Model) renderLeadSelector() string { if opt.count == 1 { noun = "dossier" } - line := fmt.Sprintf("%-24s %d %s", opt.filter.label(), opt.count, noun) + line := fmt.Sprintf("%-24s %d %s", m.leadOptionLabel(opt), opt.count, noun) if i == m.leadCursor { sb.WriteString(focusedItemStyle.Render(cursor + line)) } else { @@ -1850,13 +1892,17 @@ func (m Model) View() string { sb.WriteString("\n") case ViewDashboard: - sb.WriteString(subtitleStyle.Render(fmt.Sprintf(" Durable memory layer for agentic workflows — Dashboard · Lead: %s", m.leadFilter.label()))) + archivedNote := "" + if m.hideResolvedArchived { + archivedNote = " · resolved/archived hidden" + } + sb.WriteString(subtitleStyle.Render(fmt.Sprintf(" Durable memory layer for agentic workflows — Dashboard · Lead: %s%s", m.leadFilter.label(), archivedNote))) sb.WriteString("\n\n") if m.loading && len(m.items) == 0 { sb.WriteString(" Loading dossiers...\n") } else if len(m.visibleItems) == 0 { - sb.WriteString(subtitleStyle.Render(fmt.Sprintf(" No dossiers for lead: %s — press f to choose another.\n", m.leadFilter.label()))) + sb.WriteString(subtitleStyle.Render(fmt.Sprintf(" No dossiers for lead: %s — press f to change filters.\n", m.leadFilter.label()))) } else { sb.WriteString(m.table.View()) sb.WriteString("\n") @@ -1996,10 +2042,10 @@ func (m Model) View() string { } } - keyHelp := "↑/↓: select • enter: detail • f: filter lead • s: status • l: lead • p: priority • n: next action • k: link • m: merge • q: quit" + keyHelp := "↑/↓: select • enter: detail • f: filters • s: status • l: lead • p: priority • n: next action • k: link • m: merge • q: quit" switch m.currentView { case ViewLeadSelector: - keyHelp = "type: search • ↑/↓: select • enter: open dashboard • esc: show all • ctrl+c: quit" + keyHelp = "type: search leads • ↑/↓: select • enter: apply • esc: cancel • ctrl+c: quit" case ViewDetail: keyHelp = "↑/↓/pgup/pgdn: scroll • s: status • l: lead • p: priority • n: next action • esc: back • q: quit" case ViewStatusPicker: diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 3b1b77f..f10ae28 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -392,6 +392,28 @@ func TestTUI_InlineEditing(t *testing.T) { } } +// TestStartEditPropagatesBaseRevision guards against a regression where +// startEditStatus/startEditLead forgot to copy the target's base revision into +// the model, leaving a stale (or empty) revision from whatever edit ran +// previously. Against the real store that stale revision causes a spurious +// concurrency-conflict on save, so the user's status/lead change is silently +// rejected instead of applied. +func TestStartEditPropagatesBaseRevision(t *testing.T) { + target := targetDossier{id: "dos1", name: "Project Alpha", baseRevision: "rev_abc123"} + + var m Model + m.startEditStatus(target) + if m.targetBaseRevision != "rev_abc123" { + t.Errorf("startEditStatus: targetBaseRevision = %q, want %q", m.targetBaseRevision, "rev_abc123") + } + + m = Model{} + m.startEditLead(target) + if m.targetBaseRevision != "rev_abc123" { + t.Errorf("startEditLead: targetBaseRevision = %q, want %q", m.targetBaseRevision, "rev_abc123") + } +} + // TestTUI_NoActiveBinding asserts the TUI exposes no per-session "active" // affordance: pressing 'a' is a no-op, and the dashboard has no ★ marker. The // per-session active binding (Switch) is intentionally not reachable from the @@ -621,17 +643,21 @@ func TestDeriveLeadOptions(t *testing.T) { {ID: "2", Name: "Beta", Lead: ""}, {ID: "3", Name: "Gamma", Lead: "alice"}, {ID: "4", Name: "Delta", Lead: "Bob"}, + {ID: "5", Name: "Epsilon", Lead: "Bob", Status: "archived"}, {ID: "", Name: "placeholder"}, // header/placeholder row must be ignored } - got := deriveLeadOptions(items) + got := deriveLeadOptions(items, true) - // All and Unassigned are pinned first; named leads follow case-insensitively sorted. + // All and Unassigned are pinned first; named leads follow case-insensitively + // sorted; the archived/resolved toggle is always last. The archived item is + // excluded from every count above since hideResolvedArchived is true. want := []leadOption{ {filter: leadFilter{kind: filterAll}, count: 4}, {filter: leadFilter{kind: filterUnassigned}, count: 1}, {filter: leadFilter{kind: filterByName, name: "alice"}, count: 1}, {filter: leadFilter{kind: filterByName, name: "Bob"}, count: 2}, + {isArchivedToggle: true, count: 1}, } if len(got) != len(want) { @@ -642,12 +668,30 @@ func TestDeriveLeadOptions(t *testing.T) { t.Errorf("option %d = %+v, want %+v", i, got[i], want[i]) } } + + // With hideResolvedArchived false, the archived item counts toward Bob and All. + shown := deriveLeadOptions(items, false) + wantShown := []leadOption{ + {filter: leadFilter{kind: filterAll}, count: 5}, + {filter: leadFilter{kind: filterUnassigned}, count: 1}, + {filter: leadFilter{kind: filterByName, name: "alice"}, count: 1}, + {filter: leadFilter{kind: filterByName, name: "Bob"}, count: 3}, + {isArchivedToggle: true, count: 1}, + } + if len(shown) != len(wantShown) { + t.Fatalf("got %d options, want %d: %+v", len(shown), len(wantShown), shown) + } + for i := range wantShown { + if shown[i] != wantShown[i] { + t.Errorf("option %d = %+v, want %+v", i, shown[i], wantShown[i]) + } + } } func TestDeriveLeadOptionsEmpty(t *testing.T) { - got := deriveLeadOptions(nil) - if len(got) != 2 { - t.Fatalf("expected All + Unassigned even with no items, got %d", len(got)) + got := deriveLeadOptions(nil, true) + if len(got) != 3 { + t.Fatalf("expected All + Unassigned + archived toggle even with no items, got %d", len(got)) } if got[0].filter.kind != filterAll || got[0].count != 0 { t.Errorf("expected All with count 0, got %+v", got[0]) @@ -655,6 +699,9 @@ func TestDeriveLeadOptionsEmpty(t *testing.T) { if got[1].filter.kind != filterUnassigned || got[1].count != 0 { t.Errorf("expected Unassigned with count 0, got %+v", got[1]) } + if !got[2].isArchivedToggle || got[2].count != 0 { + t.Errorf("expected archived toggle with count 0, got %+v", got[2]) + } } func TestFilterLeadOptions(t *testing.T) { @@ -729,7 +776,7 @@ func TestChooseLeadFiltersDashboard(t *testing.T) { {ID: "2", Name: "Beta", Lead: "Alice"}, {ID: "3", Name: "Gamma", Lead: "Bob"}, } - m.leadOptions = deriveLeadOptions(m.items) + m.leadOptions = deriveLeadOptions(m.items, m.hideResolvedArchived) m.leadResults = m.leadOptions // Select "Bob" (index 3: All, Unassigned, Alice, Bob). @@ -793,6 +840,76 @@ func TestStatusTierSort(t *testing.T) { } } +// TestArchivedHiddenByDefault verifies resolved/archived dossiers stay out of +// the dashboard's visible items until the user toggles them on via the filter +// screen's archived/resolved row. +func TestArchivedHiddenByDefault(t *testing.T) { + store := newTestStore() + store.dossiers["arch"] = &core.Dossier{ + Frontmatter: core.Frontmatter{ + ID: "arch", + Name: "Old Project", + Slug: "arch", + Status: core.StatusArchived, + LastTouchedAt: testClock{}.Now(), + }, + } + store.dossiers["res"] = &core.Dossier{ + Frontmatter: core.Frontmatter{ + ID: "res", + Name: "Done Project", + Slug: "res", + Status: core.StatusResolved, + LastTouchedAt: testClock{}.Now(), + }, + } + store.dossiers["act"] = &core.Dossier{ + Frontmatter: core.Frontmatter{ + ID: "act", + Name: "Live Project", + Slug: "act", + Status: core.StatusActive, + LastTouchedAt: testClock{}.Now(), + }, + } + svc := setupTestService(store) + m := NewModel(svc) + m.width = 100 + m.height = 40 + m.recalculateTableLayout() + + listMsg := m.listDossiersCmd()() + newM, _ := m.Update(listMsg) + m = newM.(Model) + m = enterDashboard(t, m) + + if !m.hideResolvedArchived { + t.Fatal("expected hideResolvedArchived to default to true") + } + if len(m.visibleItems) != 1 || m.visibleItems[0].ID != "act" { + t.Fatalf("expected only the active dossier visible by default, got %+v", m.visibleItems) + } + + // Open the filter screen and select the archived/resolved toggle (last row). + m.openLeadSelector() + m.leadCursor = len(m.leadResults) - 1 + if !m.leadResults[m.leadCursor].isArchivedToggle { + t.Fatalf("expected last row to be the archived toggle, got %+v", m.leadResults[m.leadCursor]) + } + newM, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = newM.(Model) + + if m.currentView != ViewDashboard { + t.Fatalf("expected toggle to return to ViewDashboard, got %v", m.currentView) + } + if m.hideResolvedArchived { + t.Fatal("expected hideResolvedArchived to flip to false") + } + if len(m.visibleItems) != 3 { + t.Fatalf("expected all 3 dossiers visible after toggling, got %+v", m.visibleItems) + } +} + // TestEnterRecallsFilteredDossier exercises the exact desync the visibleItems // refactor exists to prevent: with a lead filter active, pressing enter on the // first visible row must recall that dossier, not the same index of the full list.