From a748b77ca1c70001c80f150ec5b9f4794798f122 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 7 Jul 2026 07:30:18 -0500 Subject: [PATCH 1/3] feat(detail): pinned task quick-nav bar honoring board filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a compact pill bar at the top of the task detail view for hopping between the tasks you're currently focused on — your pinned tasks — without leaving the detail view. It respects the board's active filter, so a board filtered to "[projectX]" surfaces only projectX's pins, not every pin across the board. - New DetailModel pinned quick-nav: PinnedNavItem + SetPinnedNav, a windowed single-line pill bar rendered in the header (so viewSignature captures it and the render cache stays correct). The bar hides when there's nowhere to hop (no pins, or the only pin is the current task), and grows headerHeight by one line when shown so the viewport reflows. - Keys in the detail view: [ / ] cycle prev/next pinned task (wrapping), 1-9 jump directly to that pill. Navigation reuses the existing prev/next transition path (CleanupWithoutSaving + loadTask under the taskTransitionInProgress guard), which already releases and re-acquires the per-task executor flock — so rapidly switching pinned tasks never fights over an executor pane. - AppModel.filteredPinnedTasks applies the same fuzzy filter predicate the board uses (scoreTaskForFilter), ordered by status column then ID. The bar is refreshed on task open and on task-set changes (pin/unpin). The reported "multiple ty instances mixing up executor windows" is already fixed on main by the granular per-task executor flock (d733659c): a second instance opening the SAME task's executor is refused (shown as busy) while different tasks coexist freely. This change deliberately routes pinned-nav switching through that same lock path and adds coverage; no lock changes were needed. Tests: pinned-nav visibility/wrap/quick-jump, header rendering + headerHeight, filter-respect (incl. [project] bracket syntax), and an integration test driving the real updateDetail keypress path. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/app.go | 119 ++++++++++++++ internal/ui/detail.go | 58 +++++-- internal/ui/detail_pinned_nav.go | 179 +++++++++++++++++++++ internal/ui/detail_pinned_nav_test.go | 126 +++++++++++++++ internal/ui/pinned_filter_test.go | 70 ++++++++ internal/ui/pinned_nav_integration_test.go | 81 ++++++++++ 6 files changed, 621 insertions(+), 12 deletions(-) create mode 100644 internal/ui/detail_pinned_nav.go create mode 100644 internal/ui/detail_pinned_nav_test.go create mode 100644 internal/ui/pinned_filter_test.go create mode 100644 internal/ui/pinned_nav_integration_test.go diff --git a/internal/ui/app.go b/internal/ui/app.go index f9ab1c84..6ccd3d90 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -91,6 +91,9 @@ type KeyMap struct { // Jump to pinned/unpinned tasks JumpToPinned key.Binding JumpToUnpinned key.Binding + // Detail-view pinned quick-nav (hop between focused/pinned tasks) + PrevPinnedTask key.Binding + NextPinnedTask key.Binding // Column collapse CollapseBacklog key.Binding CollapseDone key.Binding @@ -257,6 +260,14 @@ func DefaultKeyMap() KeyMap { key.WithKeys("shift+down"), key.WithHelp(IconShiftDown(), "jump to unpinned"), ), + PrevPinnedTask: key.NewBinding( + key.WithKeys("["), + key.WithHelp("[", "prev pinned"), + ), + NextPinnedTask: key.NewBinding( + key.WithKeys("]"), + key.WithHelp("]", "next pinned"), + ), CollapseBacklog: key.NewBinding( key.WithKeys("["), key.WithHelp("[", "collapse backlog"), @@ -978,6 +989,9 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Reapply filter if one is active m.applyFilter() + // Keep the detail view's pinned quick-nav in sync with the new task set + // (e.g. after a pin/unpin, which reloads tasks). + m.refreshDetailPinnedNav() m.kanban.SetHiddenDoneCount(msg.hiddenDoneCount) // Refresh running process indicators for all tasks running := executor.GetTasksWithRunningShellProcess() @@ -1058,6 +1072,8 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Set task position in column for display pos, total := m.kanban.GetTaskPosition() m.detailView.SetPosition(pos, total) + // Populate the pinned quick-nav bar (respects the active board filter) + m.refreshDetailPinnedNav() m.previousView = m.currentView m.currentView = ViewDetail // Start async pane setup if needed @@ -2255,6 +2271,70 @@ func (m *AppModel) resolveProjectAliases(query string) string { return result.String() } +// filteredPinnedTasks returns the pinned tasks that pass the board's currently +// active filter, ordered by status column then ID for stability. This is the set +// the detail view's quick-nav bar hops between — so a filtered board ("[projectX]") +// only surfaces its own pinned tasks, not every pin across the board. +func (m *AppModel) filteredPinnedTasks() []*db.Task { + queryLower := strings.ToLower(m.filterText) + if strings.Contains(queryLower, "[") { + queryLower = m.resolveProjectAliases(queryLower) + } + + var pinned []*db.Task + for _, task := range m.tasks { + if !task.Pinned { + continue + } + if queryLower != "" && scoreTaskForFilter(task, queryLower) < 0 { + continue + } + pinned = append(pinned, task) + } + + sort.SliceStable(pinned, func(i, j int) bool { + ri, rj := statusColumnRank(pinned[i].Status), statusColumnRank(pinned[j].Status) + if ri != rj { + return ri < rj + } + return pinned[i].ID < pinned[j].ID + }) + return pinned +} + +// statusColumnRank orders task statuses left-to-right the way the board columns +// read, so the pinned nav bar lists tasks in a familiar order. +func statusColumnRank(status string) int { + switch status { + case db.StatusBacklog: + return 0 + case db.StatusQueued: + return 1 + case db.StatusProcessing: + return 2 + case db.StatusBlocked: + return 3 + case db.StatusDone: + return 4 + default: + return 5 + } +} + +// refreshDetailPinnedNav rebuilds the detail view's pinned quick-nav bar from the +// current task set and filter. Safe to call when not in the detail view. +func (m *AppModel) refreshDetailPinnedNav() { + if m.detailView == nil || m.selectedTask == nil { + return + } + pinned := m.filteredPinnedTasks() + items := make([]PinnedNavItem, len(pinned)) + for i, t := range pinned { + items[i] = PinnedNavItem{ID: t.ID, Title: t.Title, Status: t.Status, Project: t.Project} + } + m.detailView.SetPinnedNav(items, m.selectedTask.ID) +} + // applyFilter filters the tasks based on current filter text using fuzzy matching. // Uses the same matching logic as the command palette (Ctrl+P) for consistency. func (m *AppModel) applyFilter() { @@ -2608,6 +2688,22 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } + // Pinned quick-nav: hop between the pinned tasks shown in the detail view's + // top bar. [ / ] cycle prev/next; 1-9 jump to that pill directly. + if m.detailView != nil && m.detailView.HasPinnedNav() { + if key.Matches(keyMsg, m.keys.NextPinnedTask) { + return m.jumpToPinnedTask(m.detailView.PinnedNavNextID()) + } + if key.Matches(keyMsg, m.keys.PrevPinnedTask) { + return m.jumpToPinnedTask(m.detailView.PinnedNavPrevID()) + } + if s := keyMsg.String(); len(s) == 1 && s[0] >= '1' && s[0] <= '9' { + if id := m.detailView.PinnedNavIDAt(int(s[0] - '0')); id != 0 { + return m.jumpToPinnedTask(id) + } + } + } + if m.detailView != nil { var cmd tea.Cmd m.detailView, cmd = m.detailView.Update(msg) @@ -2617,6 +2713,29 @@ func (m *AppModel) updateDetail(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// jumpToPinnedTask switches the detail view to the given task, reusing the same +// cleanup/transition guard as prev/next navigation so we never fight over the +// executor pane. It selects the task in the kanban first so position display and +// subsequent arrow navigation stay consistent. +func (m *AppModel) jumpToPinnedTask(id int64) (tea.Model, tea.Cmd) { + if id == 0 { + return m, nil + } + if m.selectedTask != nil && m.selectedTask.ID == id { + return m, nil // already here + } + if m.taskTransitionInProgress { + return m, nil + } + m.taskTransitionInProgress = true + if m.detailView != nil { + m.detailView.CleanupWithoutSaving() + m.detailView = nil + } + m.kanban.SelectTask(id) + return m, m.loadTask(id) +} + func (m *AppModel) updateNewTaskForm(msg tea.Msg) (tea.Model, tea.Cmd) { // Pass all messages to the form (form handles ESC with confirmation prompt) model, cmd := m.newTaskForm.Update(msg) diff --git a/internal/ui/detail.go b/internal/ui/detail.go index e60e563e..cd430bd9 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -116,6 +116,15 @@ type DetailModel struct { positionInColumn int totalInColumn int + // Pinned quick-nav: a compact pill bar at the top of the detail view for + // hopping between the tasks you're currently focused on (your pinned tasks, + // honouring the board's active filter). pinnedNavIndex is the position of the + // currently-viewed task within pinnedNav, or -1 when the current task isn't + // pinned. Populated by AppModel via SetPinnedNav whenever a task is opened or + // the task set changes. + pinnedNav []PinnedNavItem + pinnedNavIndex int + // Track joined tmux panes claudePaneID string // The Claude Code pane (middle-left) workdirPaneID string // The workdir shell pane (middle-right) @@ -777,16 +786,22 @@ func (m *DetailModel) spinnerTick() tea.Cmd { }) } +// headerHeight is the vertical space reserved for the header above the viewport. +// The pinned quick-nav bar, when shown, adds one line and must be accounted for +// here or the viewport would paint over it. +func (m *DetailModel) headerHeight() int { + h := 6 + if m.showPinnedNav() { + h++ + } + return h +} + func (m *DetailModel) initViewport() { - headerHeight := 6 footerHeight := 2 // If we have joined panes, we have less height (tmux split takes space) - vpHeight := m.height - headerHeight - footerHeight - if m.claudePaneID != "" || m.workdirPaneID != "" { - // The tmux split takes roughly half, but we don't control that here - // Just use full height - the TUI pane will be resized by tmux - } + vpHeight := m.height - m.headerHeight() - footerHeight m.viewport = viewport.New(m.width-4, vpHeight) m.setViewportContent() @@ -797,13 +812,20 @@ func (m *DetailModel) initViewport() { func (m *DetailModel) SetSize(width, height int) { m.width = width m.height = height - if m.ready { - headerHeight := 6 - footerHeight := 2 - m.viewport.Width = width - 4 - m.viewport.Height = height - headerHeight - footerHeight - m.setViewportContent() + m.reflowViewport() +} + +// reflowViewport recomputes the viewport dimensions from the current width, +// height, and header layout. Called on resize and whenever something that +// changes the header height (like the pinned nav bar appearing) is updated. +func (m *DetailModel) reflowViewport() { + if !m.ready { + return } + footerHeight := 2 + m.viewport.Width = m.width - 4 + m.viewport.Height = m.height - m.headerHeight() - footerHeight + m.setViewportContent() } // Update handles messages. @@ -2842,6 +2864,13 @@ func (m *DetailModel) renderHeader() string { Align(lipgloss.Right). Render(rightBlock) + // Prepend the pinned quick-nav bar (when there's somewhere to hop to). It is + // part of the header string, so viewSignature captures it automatically and + // the render cache stays correct. + if nav := m.renderPinnedNav(); nav != "" { + return lipgloss.JoinVertical(lipgloss.Left, nav, headerLayout, "") + } + return lipgloss.JoinVertical(lipgloss.Left, headerLayout, "") } @@ -3135,6 +3164,11 @@ func (m *DetailModel) renderHelp() string { {IconArrowUp() + "/" + IconArrowDown(), "prev/next task", !hasNavigation}, } + // Pinned quick-nav hint (only when the bar is shown) + if m.showPinnedNav() { + keys = append(keys, helpKey{"[/]", "pinned", false}) + } + // Show scroll hint when content is scrollable if m.viewport.TotalLineCount() > m.viewport.VisibleLineCount() { keys = append(keys, helpKey{"j/k/wheel", "scroll", false}) diff --git a/internal/ui/detail_pinned_nav.go b/internal/ui/detail_pinned_nav.go new file mode 100644 index 00000000..483108ed --- /dev/null +++ b/internal/ui/detail_pinned_nav.go @@ -0,0 +1,179 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// PinnedNavItem is a single entry in the detail view's pinned quick-nav bar. +// It carries only what the bar needs to render and to load the task on jump. +type PinnedNavItem struct { + ID int64 + Title string + Status string + Project string +} + +// SetPinnedNav updates the pinned quick-nav bar for this detail view. items is +// the ordered set of pinned tasks (already filtered to match the board's active +// filter); currentID is the task currently being viewed, used to highlight its +// pill and anchor "next/prev" navigation. Toggling the bar's visibility changes +// the header height, so the viewport is reflowed to match. +func (m *DetailModel) SetPinnedNav(items []PinnedNavItem, currentID int64) { + m.pinnedNav = items + m.pinnedNavIndex = -1 + for i, it := range items { + if it.ID == currentID { + m.pinnedNavIndex = i + break + } + } + m.reflowViewport() + // The header changed; drop the render cache so the bar paints immediately. + m.cachedViewOK = false +} + +// HasPinnedNav reports whether the pinned quick-nav bar is currently shown. +func (m *DetailModel) HasPinnedNav() bool { + return m.showPinnedNav() +} + +// showPinnedNav decides whether the bar is worth showing. We hide it when there +// is nowhere to hop to: no pinned tasks, or the only pinned task is the one +// already open. +func (m *DetailModel) showPinnedNav() bool { + n := len(m.pinnedNav) + if n == 0 { + return false + } + if n == 1 && m.pinnedNavIndex == 0 { + return false + } + return true +} + +// PinnedNavNextID returns the ID of the pinned task after the current one +// (wrapping around), or 0 if navigation isn't possible. +func (m *DetailModel) PinnedNavNextID() int64 { + n := len(m.pinnedNav) + if n == 0 { + return 0 + } + idx := m.pinnedNavIndex + if idx < 0 { + return m.pinnedNav[0].ID + } + return m.pinnedNav[(idx+1)%n].ID +} + +// PinnedNavPrevID returns the ID of the pinned task before the current one +// (wrapping around), or 0 if navigation isn't possible. +func (m *DetailModel) PinnedNavPrevID() int64 { + n := len(m.pinnedNav) + if n == 0 { + return 0 + } + idx := m.pinnedNavIndex + if idx < 0 { + return m.pinnedNav[n-1].ID + } + return m.pinnedNav[(idx-1+n)%n].ID +} + +// PinnedNavIDAt returns the ID of the nth pinned task (1-indexed), or 0 if there +// is no such task. Backs the 1-9 quick-jump keys. +func (m *DetailModel) PinnedNavIDAt(n int) int64 { + if n < 1 || n > len(m.pinnedNav) { + return 0 + } + return m.pinnedNav[n-1].ID +} + +// renderPinnedNav builds the single-line pill bar. It windows the pills around +// the current one so the active task is always visible even with many pins, and +// never exceeds the available width (which would wrap and break header sizing). +func (m *DetailModel) renderPinnedNav() string { + if !m.showPinnedNav() { + return "" + } + + label := lipgloss.NewStyle().Foreground(ColorMuted).Render(IconPin() + " ") + + currentStyle := lipgloss.NewStyle(). + Background(ColorPrimary). + Foreground(lipgloss.Color("#FFFFFF")). + Bold(true) + restStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#374151")). + Foreground(lipgloss.Color("#D1D5DB")) + dimStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("#374151")). + Foreground(lipgloss.Color("#6B7280")) + moreStyle := lipgloss.NewStyle().Foreground(ColorMuted) + + n := len(m.pinnedNav) + pills := make([]string, n) + widths := make([]int, n) + for i, it := range m.pinnedNav { + text := fmt.Sprintf(" #%d %s ", it.ID, truncateRunes(it.Title, 18)) + if i < 9 { + // Prefix with the 1-9 quick-jump number. + text = fmt.Sprintf(" %d·#%d %s ", i+1, it.ID, truncateRunes(it.Title, 18)) + } + var style lipgloss.Style + switch { + case i == m.pinnedNavIndex: + style = currentStyle + case m.focused: + style = restStyle + default: + style = dimStyle + } + pills[i] = style.Render(text) + widths[i] = lipgloss.Width(pills[i]) + } + + // Budget for pills after the label, leaving room for edge "…" markers. + avail := m.width - lipgloss.Width(label) - 6 + if avail < 10 { + avail = 10 + } + + // Window outward from the current pill (or the first pill when the current + // task isn't pinned) until adding another would overflow. + anchor := m.pinnedNavIndex + if anchor < 0 { + anchor = 0 + } + start, end := anchor, anchor+1 + used := widths[anchor] + for { + grew := false + if end < n && used+1+widths[end] <= avail { + used += 1 + widths[end] + end++ + grew = true + } + if start > 0 && used+1+widths[start-1] <= avail { + used += 1 + widths[start-1] + start-- + grew = true + } + if !grew { + break + } + } + + var b strings.Builder + b.WriteString(label) + if start > 0 { + b.WriteString(moreStyle.Render(fmt.Sprintf("‹%d ", start))) + } + b.WriteString(strings.Join(pills[start:end], " ")) + if end < n { + b.WriteString(moreStyle.Render(fmt.Sprintf(" %d›", n-end))) + } + return b.String() +} diff --git a/internal/ui/detail_pinned_nav_test.go b/internal/ui/detail_pinned_nav_test.go new file mode 100644 index 00000000..d016cb40 --- /dev/null +++ b/internal/ui/detail_pinned_nav_test.go @@ -0,0 +1,126 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/bborn/workflow/internal/db" +) + +// newNavDetailModel builds a minimal, tmux-free DetailModel suitable for +// exercising the pinned quick-nav logic in isolation. +func newNavDetailModel(t *testing.T) *DetailModel { + t.Helper() + t.Setenv("TMUX", "") // no tmux: NewDetailModel returns without pane setup + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + + task := &db.Task{ID: 1, Title: "Current", Status: db.StatusProcessing, Body: "body"} + if err := database.CreateTask(task); err != nil { + t.Fatalf("create task: %v", err) + } + m, _ := NewDetailModel(task, database, nil, 160, 50, false) + return m +} + +func TestPinnedNavVisibility(t *testing.T) { + m := newNavDetailModel(t) + + // No pins: bar hidden. + if m.HasPinnedNav() { + t.Error("expected no pinned nav with zero items") + } + + // A single pin that is the current task: nothing to hop to, so hidden. + m.SetPinnedNav([]PinnedNavItem{{ID: 1, Title: "Current"}}, 1) + if m.HasPinnedNav() { + t.Error("expected no pinned nav when the only pin is the current task") + } + + // A single pin that is a *different* task: worth showing. + m.SetPinnedNav([]PinnedNavItem{{ID: 2, Title: "Other"}}, 1) + if !m.HasPinnedNav() { + t.Error("expected pinned nav when there's a different pinned task to jump to") + } + + // Multiple pins including the current one: shown. + m.SetPinnedNav([]PinnedNavItem{{ID: 1, Title: "Current"}, {ID: 2, Title: "Other"}}, 1) + if !m.HasPinnedNav() { + t.Error("expected pinned nav with multiple pinned tasks") + } +} + +func TestPinnedNavNextPrevWrap(t *testing.T) { + m := newNavDetailModel(t) + items := []PinnedNavItem{{ID: 10, Title: "a"}, {ID: 20, Title: "b"}, {ID: 30, Title: "c"}} + + // Current is the middle item. + m.SetPinnedNav(items, 20) + if got := m.PinnedNavNextID(); got != 30 { + t.Errorf("next from middle: got %d, want 30", got) + } + if got := m.PinnedNavPrevID(); got != 10 { + t.Errorf("prev from middle: got %d, want 10", got) + } + + // Wrap around from the ends. + m.SetPinnedNav(items, 30) + if got := m.PinnedNavNextID(); got != 10 { + t.Errorf("next should wrap to first: got %d, want 10", got) + } + m.SetPinnedNav(items, 10) + if got := m.PinnedNavPrevID(); got != 30 { + t.Errorf("prev should wrap to last: got %d, want 30", got) + } + + // Current task not in the pinned set (viewing an unpinned task): next/prev + // still land on the ends of the list. + m.SetPinnedNav(items, 999) + if got := m.PinnedNavNextID(); got != 10 { + t.Errorf("next with no current index: got %d, want 10", got) + } + if got := m.PinnedNavPrevID(); got != 30 { + t.Errorf("prev with no current index: got %d, want 30", got) + } +} + +func TestPinnedNavIDAt(t *testing.T) { + m := newNavDetailModel(t) + items := []PinnedNavItem{{ID: 10}, {ID: 20}, {ID: 30}} + m.SetPinnedNav(items, 10) + + if got := m.PinnedNavIDAt(1); got != 10 { + t.Errorf("PinnedNavIDAt(1)=%d, want 10", got) + } + if got := m.PinnedNavIDAt(3); got != 30 { + t.Errorf("PinnedNavIDAt(3)=%d, want 30", got) + } + // Out of range returns 0 (no-op). + if got := m.PinnedNavIDAt(0); got != 0 { + t.Errorf("PinnedNavIDAt(0)=%d, want 0", got) + } + if got := m.PinnedNavIDAt(4); got != 0 { + t.Errorf("PinnedNavIDAt(4)=%d, want 0", got) + } +} + +func TestPinnedNavRendersInHeader(t *testing.T) { + m := newNavDetailModel(t) + m.SetPinnedNav([]PinnedNavItem{ + {ID: 1, Title: "Current"}, + {ID: 42, Title: "Refactor login"}, + }, 1) + + header := m.renderHeader() + if !strings.Contains(header, "#42") { + t.Errorf("expected pinned nav bar to reference task #42 in header, got:\n%s", header) + } + // The bar adds a line, so headerHeight must grow to keep the viewport clear. + if m.headerHeight() != 7 { + t.Errorf("headerHeight with nav = %d, want 7", m.headerHeight()) + } +} diff --git a/internal/ui/pinned_filter_test.go b/internal/ui/pinned_filter_test.go new file mode 100644 index 00000000..75bed951 --- /dev/null +++ b/internal/ui/pinned_filter_test.go @@ -0,0 +1,70 @@ +package ui + +import ( + "testing" + + "github.com/bborn/workflow/internal/db" +) + +// TestFilteredPinnedTasksRespectsFilter is the core ticket requirement: the +// detail view's pinned quick-nav must honour the board's active filter, so a +// board filtered to "[projectX]" surfaces only projectX's pinned tasks, not +// every pin across the board. +func TestFilteredPinnedTasksRespectsFilter(t *testing.T) { + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer database.Close() + + m := &AppModel{db: database} + m.tasks = []*db.Task{ + {ID: 1, Title: "alpha", Project: "projectx", Status: db.StatusProcessing, Pinned: true}, + {ID: 2, Title: "beta", Project: "projectx", Status: db.StatusBacklog, Pinned: false}, // not pinned + {ID: 3, Title: "gamma", Project: "other", Status: db.StatusBlocked, Pinned: true}, // pinned, wrong project + {ID: 4, Title: "delta", Project: "projectx", Status: db.StatusBlocked, Pinned: true}, + } + + // No filter: every pinned task, ordered by status column then ID. + // Statuses: #1 processing(2); #3 & #4 both blocked(3), so ID-ascending -> [1,3,4] + got := ids(m.filteredPinnedTasks()) + want := []int64{1, 3, 4} + if !equalIDs(got, want) { + t.Errorf("no filter: got %v, want %v", got, want) + } + + // Filter to projectx via bracket syntax: only pinned projectx tasks. + m.filterText = "[projectx]" + got = ids(m.filteredPinnedTasks()) + want = []int64{1, 4} + if !equalIDs(got, want) { + t.Errorf("[projectx] filter: got %v, want %v (should exclude pinned task from 'other')", got, want) + } + + // Filter to a project with no pinned tasks: empty result. + m.filterText = "[other] gamma" + got = ids(m.filteredPinnedTasks()) + if len(got) != 1 || got[0] != 3 { + t.Errorf("[other] gamma filter: got %v, want [3]", got) + } +} + +func ids(tasks []*db.Task) []int64 { + out := make([]int64, len(tasks)) + for i, t := range tasks { + out[i] = t.ID + } + return out +} + +func equalIDs(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/ui/pinned_nav_integration_test.go b/internal/ui/pinned_nav_integration_test.go new file mode 100644 index 00000000..efbb489f --- /dev/null +++ b/internal/ui/pinned_nav_integration_test.go @@ -0,0 +1,81 @@ +package ui + +import ( + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/bborn/workflow/internal/config" + "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/executor" +) + +// TestUpdateDetailPinnedJump drives the real detail-view key handler: with the +// pinned quick-nav bar populated, pressing "]" must start a task transition to +// the next pinned task (reusing the same guard as prev/next navigation), and a +// digit must jump directly to that pill. +func TestUpdateDetailPinnedJump(t *testing.T) { + t.Setenv("TMUX", "") // keep DetailModel tmux-free + + database, err := db.Open(":memory:") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer database.Close() + + exec := executor.New(database, &config.Config{}) + + current := &db.Task{Title: "current", Status: db.StatusProcessing, Pinned: true} + other := &db.Task{Title: "other", Status: db.StatusProcessing, Pinned: true} + for _, tk := range []*db.Task{current, other} { + if err := database.CreateTask(tk); err != nil { + t.Fatalf("create task: %v", err) + } + } + + detail, _ := NewDetailModel(current, database, exec, 120, 40, false) + detail.SetPinnedNav([]PinnedNavItem{ + {ID: current.ID, Title: "current"}, + {ID: other.ID, Title: "other"}, + }, current.ID) + + kanban := NewKanbanBoard(120, 40) + kanban.SetTasks([]*db.Task{current, other}) + + m := &AppModel{ + width: 120, + height: 40, + currentView: ViewDetail, + db: database, + executor: exec, + keys: DefaultKeyMap(), + kanban: kanban, + detailView: detail, + selectedTask: current, + lastViewedAt: map[int64]time.Time{}, + } + + // Press "]" -> transition to next pinned task should begin. + model, cmd := m.updateDetail(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("]")}) + am := model.(*AppModel) + if !am.taskTransitionInProgress { + t.Fatal(`pressing "]" should begin a task transition to the next pinned task`) + } + if cmd == nil { + t.Fatal(`pressing "]" should return a loadTask command`) + } + + // Reset and try a direct digit jump to pill 2 (the "other" task). + am.taskTransitionInProgress = false + am.detailView, _ = NewDetailModel(current, database, exec, 120, 40, false) + am.detailView.SetPinnedNav([]PinnedNavItem{ + {ID: current.ID, Title: "current"}, + {ID: other.ID, Title: "other"}, + }, current.ID) + + _, cmd = am.updateDetail(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")}) + if cmd == nil || !am.taskTransitionInProgress { + t.Fatal(`pressing "2" should jump directly to the second pinned task`) + } +} From a9a409b3432d4e47d28e5da35bc054d4093fd1a1 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 7 Jul 2026 14:03:13 -0500 Subject: [PATCH 2/3] refactor(detail): move pinned quick-nav bar to the bottom of the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design feedback, render the pinned quick-nav pill bar at the bottom of the detail view (just above the help footer) instead of the top, so the top stays clean (status/badges). The reserved chrome line already accounted for by headerHeight() is unchanged — only the render position moves — and the bar is now folded into viewSignature directly (it's no longer part of the header string). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/ui/detail.go | 26 ++++++++++++++------------ internal/ui/detail_pinned_nav_test.go | 15 ++++++++++----- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/internal/ui/detail.go b/internal/ui/detail.go index cd430bd9..c6c99818 100644 --- a/internal/ui/detail.go +++ b/internal/ui/detail.go @@ -786,9 +786,9 @@ func (m *DetailModel) spinnerTick() tea.Cmd { }) } -// headerHeight is the vertical space reserved for the header above the viewport. -// The pinned quick-nav bar, when shown, adds one line and must be accounted for -// here or the viewport would paint over it. +// headerHeight is the vertical space reserved for the box chrome around the +// viewport. The pinned quick-nav bar (rendered at the bottom of the box) adds +// one line and must be accounted for here or the viewport would overflow. func (m *DetailModel) headerHeight() int { h := 6 if m.showPinnedNav() { @@ -2461,8 +2461,9 @@ func (m *DetailModel) View() string { header := m.renderHeader() help := m.renderHelp() + nav := m.renderPinnedNav() - sig := m.viewSignature(header, help) + sig := m.viewSignature(header, help, nav) if m.cachedViewOK && m.cachedViewSig == sig { return m.cachedView } @@ -2515,6 +2516,13 @@ func (m *DetailModel) View() string { boxContent = lipgloss.JoinVertical(lipgloss.Left, header, content, scrollIndicator) } + // Pinned quick-nav bar sits at the bottom of the box, just above the help + // footer, so the top stays clean. headerHeight() already reserves a line for + // it, so the viewport shrinks to make room. + if nav != "" { + boxContent = lipgloss.JoinVertical(lipgloss.Left, boxContent, nav) + } + renderedBox := box.Render(boxContent) // When shell pane is hidden, show a collapsed indicator on the right @@ -2559,7 +2567,7 @@ func (m *DetailModel) View() string { // colours) without enumerating each one. The remaining inputs are the View-level // state the header/help don't cover: the dangerous-mode banner, the bordered box, // and the viewport's content/scroll geometry. -func (m *DetailModel) viewSignature(header, help string) uint64 { +func (m *DetailModel) viewSignature(header, help, nav string) uint64 { h := newSigHasher() h.u64(StyleGeneration()) // theme / project colour changes h.int(m.width) @@ -2578,6 +2586,7 @@ func (m *DetailModel) viewSignature(header, help string) uint64 { h.int(m.viewport.VisibleLineCount()) h.str(header) h.str(help) + h.str(nav) // pinned quick-nav bar (rendered at the box bottom) return h.h } @@ -2864,13 +2873,6 @@ func (m *DetailModel) renderHeader() string { Align(lipgloss.Right). Render(rightBlock) - // Prepend the pinned quick-nav bar (when there's somewhere to hop to). It is - // part of the header string, so viewSignature captures it automatically and - // the render cache stays correct. - if nav := m.renderPinnedNav(); nav != "" { - return lipgloss.JoinVertical(lipgloss.Left, nav, headerLayout, "") - } - return lipgloss.JoinVertical(lipgloss.Left, headerLayout, "") } diff --git a/internal/ui/detail_pinned_nav_test.go b/internal/ui/detail_pinned_nav_test.go index d016cb40..af811f49 100644 --- a/internal/ui/detail_pinned_nav_test.go +++ b/internal/ui/detail_pinned_nav_test.go @@ -108,18 +108,23 @@ func TestPinnedNavIDAt(t *testing.T) { } } -func TestPinnedNavRendersInHeader(t *testing.T) { +func TestPinnedNavRendersInView(t *testing.T) { m := newNavDetailModel(t) m.SetPinnedNav([]PinnedNavItem{ {ID: 1, Title: "Current"}, {ID: 42, Title: "Refactor login"}, }, 1) - header := m.renderHeader() - if !strings.Contains(header, "#42") { - t.Errorf("expected pinned nav bar to reference task #42 in header, got:\n%s", header) + // The bar renders at the bottom of the box (not the header), so it appears in + // the full view but not in the header block. + if strings.Contains(m.renderHeader(), "#42") { + t.Error("pinned nav should not be in the header block (it lives at the box bottom)") } - // The bar adds a line, so headerHeight must grow to keep the viewport clear. + if !strings.Contains(m.View(), "#42") { + t.Errorf("expected pinned nav bar to reference task #42 in the view, got:\n%s", m.View()) + } + // The bar adds a line, so the reserved chrome height must grow to keep the + // viewport clear. if m.headerHeight() != 7 { t.Errorf("headerHeight with nav = %d, want 7", m.headerHeight()) } From 3fe19efea011d43bf5ec73b26a7da89ff81b10db Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 7 Jul 2026 14:45:51 -0500 Subject: [PATCH 3/3] test(parity): document the pinned quick-nav keys as TUI-only The new PrevPinnedTask/NextPinnedTask keymap actions have no desktop GUI equivalent, so the multi-surface parity gate (internal/parity) failed. They are keyboard-only affordances ([ / ]) for cycling pinned tasks in the detail view; the desktop GUI switches tasks via its own list UI and already honors the board filter. Record them as deliberate skips in parity-ignore.json. Co-Authored-By: Claude Opus 4.8 (1M context) --- parity-ignore.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/parity-ignore.json b/parity-ignore.json index 8406d11a..563d56cd 100644 --- a/parity-ignore.json +++ b/parity-ignore.json @@ -1,3 +1,5 @@ { - "SpotlightSync": "macOS Spotlight metadata export is a local-indexing concern; intentionally TUI/CLI-only." + "SpotlightSync": "macOS Spotlight metadata export is a local-indexing concern; intentionally TUI/CLI-only.", + "PrevPinnedTask": "TUI-only keyboard affordance ('[') for cycling to the previous pinned task from the detail view's quick-nav bar; the desktop GUI switches tasks via its own list/click UI and honors the same board filter, so no bracket-key binding is needed there.", + "NextPinnedTask": "TUI-only keyboard affordance (']') for cycling to the next pinned task from the detail view's quick-nav bar; the desktop GUI switches tasks via its own list/click UI and honors the same board filter, so no bracket-key binding is needed there." }