Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions panel/OutcomesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ struct OutcomesView: View {
VStack(alignment: .leading, spacing: 8) {
ForEach(groups) { group in
groupRow(group, selectedRowID: selectedRowID)
.id(Self.headerID(group))
}
}
.padding(.horizontal, 14)
Expand All @@ -97,9 +96,12 @@ struct OutcomesView: View {
.scrollIndicators(.visible)
.onChange(of: nav.outcomeSelectedIndex) { _ in
guard let selectedRowID else { return }
withAnimation(.easeOut(duration: 0.15)) {
proxy.scrollTo(selectedRowID, anchor: .center)
}
// Snap, don't animate. With key-repeat on a long list the
// 0.15s animations piled up, the scroll lagged the
// selection, then settled with an upward snap — the
// "bounce". Instant scroll keeps the selection pinned and
// the viewport tracking it smoothly.
proxy.scrollTo(selectedRowID, anchor: .center)
}
}
}
Expand Down Expand Up @@ -161,6 +163,16 @@ struct OutcomesView: View {
.fixedSize()
}
}
.padding(.vertical, 2)
// Selection highlight + scroll anchor live on the header ROW, not the
// whole group container (which spans all its branches). A
// container-wide accent lit up the entire block when a header was
// selected — reading as an upward "jump" when crossing from a group's
// last branch to the next group's header. A per-row highlight keeps
// the selection a small, consistent marker that steps straight down.
.background(RoundedRectangle(cornerRadius: 4)
.fill(selected ? Color.accentColor.opacity(0.14) : Color.clear))
.id(Self.headerID(group))
Text(detailLine(group))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Expand All @@ -184,7 +196,7 @@ struct OutcomesView: View {
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(RoundedRectangle(cornerRadius: 6)
.fill(selected ? Color.accentColor.opacity(0.14) : Color.primary.opacity(0.04)))
.fill(Color.primary.opacity(0.04)))
.contentShape(Rectangle())
.onTapGesture { if linkable { openTicket(group) } }
.help(linkable ? "Open \(group.label) in your tracker" : "")
Expand Down
16 changes: 14 additions & 2 deletions panel/PanelNav.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,13 @@ final class PanelNav: ObservableObject {
// the Tickets tab (OutcomesView) and its tab-strip count re-read the
// in-memory HandoffLedger and reflect the new session live. The ledger
// isn't itself observable; this is the single change-signal that drives it.
@Published var handoffsRevision: Int = 0
@Published var handoffsRevision: Int = 0 { didSet { cachedOutcomeGroups = nil } }
// Memoized grouping of the ledger (the expensive part: regex ticket
// attribution + branch breakdown + sorts). Invalidated only when the ledger
// changes (handoffsRevision) — NOT on selection moves, so holding ↑/↓ on the
// Tickets tab doesn't re-derive every keystroke. The hideShipped filter is
// applied per call on top, since it reads live outcome/PR state.
private var cachedOutcomeGroups: [TicketGroup]?
// Derived "did it ship?" status per repo+branch, computed off-main by
// PanelController's outcome pass and read by the Tickets tab. Keyed by
// `outcomeKey(repoRoot, branch)`. Empty until the first pass completes.
Expand Down Expand Up @@ -307,7 +313,13 @@ final class PanelNav: ObservableObject {
// The groups the Tickets tab renders, after the hide-shipped filter. Single
// source of truth so the view and the keyboard indexing never disagree.
func visibleOutcomeGroups() -> [TicketGroup] {
let groups = OutcomesView.groups(from: HandoffLedger.shared.all())
let groups: [TicketGroup]
if let cached = cachedOutcomeGroups {
groups = cached
} else {
groups = OutcomesView.groups(from: HandoffLedger.shared.all())
cachedOutcomeGroups = groups
}
guard hideShippedTickets else { return groups }
return groups.filter { !isShipped($0) }
}
Expand Down