detached 0ac83ce

fix(status): avoid live mru during contextual ranks

commit
0ac83ce293342b295c5d26e71c1da8ca5ed7b9f9
author
brice · · 2026-07-11 12:38 UTC
parent
611ca87
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +68 -9
M internal/usecase/daemon/render.go +3 -4
@@ -368,10 +368,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
368368 preview = snapshotPickerPreview(overlays.previewTab)
369369 }
370370 d.refreshSessionFocusedTitles(sess)
371- bars := d.barStateForClient(sess, ac, overlays.copyFeedback)
372- // Contextual ranks are completely captured under paletteMu. Do not consult
373- // the domain while composing: an interaction's MRU ordering is immutable.
374- bars.rankedRecent = rankedRecentForHints(overlays.paletteHints, overlays.paletteRecent)
371+ // Contextual ranks are completely captured under paletteMu. Compose them
372+ // without reading the live MRU, whose order may have changed mid-interaction.
373+ bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
375374 bars.theme = ac.getTheme()
376375 _, attentionVisible := pulseStyle(bars.attentionFrame)
377376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
@@ -368,10 +368,9 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res
368 preview = snapshotPickerPreview(overlays.previewTab)368 preview = snapshotPickerPreview(overlays.previewTab)
369 }369 }
370 d.refreshSessionFocusedTitles(sess)370 d.refreshSessionFocusedTitles(sess)
371- bars := d.barStateForClient(sess, ac, overlays.copyFeedback)371+ // Contextual ranks are completely captured under paletteMu. Compose them
372- // Contextual ranks are completely captured under paletteMu. Do not consult372+ // without reading the live MRU, whose order may have changed mid-interaction.
373- // the domain while composing: an interaction's MRU ordering is immutable.373+ bars := d.barStateForPaletteHints(sess, overlays.copyFeedback, overlays.paletteHints, overlays.paletteRecent)
374- bars.rankedRecent = rankedRecentForHints(overlays.paletteHints, overlays.paletteRecent)
375 bars.theme = ac.getTheme()374 bars.theme = ac.getTheme()
376 _, attentionVisible := pulseStyle(bars.attentionFrame)375 _, attentionVisible := pulseStyle(bars.attentionFrame)
377 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)376 repaintAttachedClients = sess.ackAttention(tb, attentionVisible)
M internal/usecase/daemon/status.go +25 -5
@@ -239,11 +239,35 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
239239 return entries
240240 }
241241
242-func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState {
242+// barStateForPaletteHints selects snapshot-only contextual composition when a
243+// palette interaction has captured recent-session hints. Normal palette state
244+// continues to use the canonical, live MRU list.
245+func (d *Daemon) barStateForPaletteHints(cur *session, copyFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
246+ ranked := rankedRecentForHints(hints, recent)
247+ if ranked != nil {
248+ return d.barStateForContextual(cur, copyFeedback, ranked)
249+ }
243250 return d.barStateFor(cur, copyFeedback)
244251 }
245252
253+// barStateForContextual composes ranks captured for the palette interaction.
254+// It deliberately does not call recentSessions: live MRU changes must not
255+// affect contextual rendering, and palette rendering must not acquire d.mu.
256+func (d *Daemon) barStateForContextual(cur *session, copyFeedback string, ranked []rankedRecent) barState {
257+ state := d.barStateBase(cur, copyFeedback)
258+ state.rankedRecent = ranked
259+ return state
260+}
261+
246262 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
263+ state := d.barStateBase(cur, copyFeedback)
264+ if d != nil {
265+ state.mru = d.recentSessions(cur)
266+ }
267+ return state
268+}
269+
270+func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState {
247271 state := barState{copyFeedback: copyFeedback}
248272 if d != nil {
249273 state.attentionFrame = d.attentionFrame()
@@ -258,10 +282,6 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
258282 if d != nil {
259283 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
260284 }
261- if d == nil {
262- return state
263- }
264- state.mru = d.recentSessions(cur)
265285 return state
266286 }
267287
@@ -239,11 +239,35 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession
239 return entries239 return entries
240 }240 }
241 241
242-func (d *Daemon) barStateForClient(cur *session, _ *attachedClient, copyFeedback string) barState {242+// barStateForPaletteHints selects snapshot-only contextual composition when a
243+// palette interaction has captured recent-session hints. Normal palette state
244+// continues to use the canonical, live MRU list.
245+func (d *Daemon) barStateForPaletteHints(cur *session, copyFeedback string, hints *palette.ContextualHints, recent []recentSession) barState {
246+ ranked := rankedRecentForHints(hints, recent)
247+ if ranked != nil {
248+ return d.barStateForContextual(cur, copyFeedback, ranked)
249+ }
243 return d.barStateFor(cur, copyFeedback)250 return d.barStateFor(cur, copyFeedback)
244 }251 }
245 252
253+// barStateForContextual composes ranks captured for the palette interaction.
254+// It deliberately does not call recentSessions: live MRU changes must not
255+// affect contextual rendering, and palette rendering must not acquire d.mu.
256+func (d *Daemon) barStateForContextual(cur *session, copyFeedback string, ranked []rankedRecent) barState {
257+ state := d.barStateBase(cur, copyFeedback)
258+ state.rankedRecent = ranked
259+ return state
260+}
261+
246 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {262 func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
263+ state := d.barStateBase(cur, copyFeedback)
264+ if d != nil {
265+ state.mru = d.recentSessions(cur)
266+ }
267+ return state
268+}
269+
270+func (d *Daemon) barStateBase(cur *session, copyFeedback string) barState {
247 state := barState{copyFeedback: copyFeedback}271 state := barState{copyFeedback: copyFeedback}
248 if d != nil {272 if d != nil {
249 state.attentionFrame = d.attentionFrame()273 state.attentionFrame = d.attentionFrame()
@@ -258,10 +282,6 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState {
258 if d != nil {282 if d != nil {
259 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)283 state.topRight, state.bottomRight = d.barScriptSnapshot(cur)
260 }284 }
261- if d == nil {
262- return state
263- }
264- state.mru = d.recentSessions(cur)
265 return state285 return state
266 }286 }
267 287
M internal/usecase/daemon/status_test.go +40 -0
@@ -12,7 +12,9 @@ import (
1212
1313 "github.com/bnema/vev/internal/domain"
1414 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/command"
1516 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/bnema/vev/internal/usecase/palette"
1618 themeui "github.com/bnema/vev/internal/usecase/theme"
1719 "github.com/bnema/vev/pkg/renderer"
1820 )
@@ -764,6 +766,44 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
764766 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
765767 }
766768
769+func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {
770+ p, releasePTY := newBlockingPTY(t)
771+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
772+ defer releasePTY()
773+ live := &session{id: "live", name: "live", tabs: []*tab{{}}}
774+ live.mruAt.Store(1)
775+ d.sessions[live.id] = live
776+
777+ hints := palette.ContextualHints{
778+ Kind: command.ContextHintRecentSessions,
779+ SelectedRank: 1,
780+ Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
781+ }
782+ // Hold the registry lock: a call to recentSessions would block here. The
783+ // contextual snapshot must still compose because it has no live MRU lookup.
784+ var contextual barState
785+ done := make(chan struct{})
786+ d.mu.Lock()
787+ go func() {
788+ contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", ephemeral: true, attention: true}})
789+ close(done)
790+ }()
791+ select {
792+ case <-done:
793+ case <-time.After(time.Second):
794+ d.mu.Unlock()
795+ t.Fatal("contextual composition consulted the live session registry")
796+ }
797+ d.mu.Unlock()
798+
799+ require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
800+ require.Equal(t, []rankedRecent{{rank: 1, name: "captured", ephemeral: true, attention: true, selected: true}}, contextual.rankedRecent)
801+
802+ normal := d.barStateFor(sess, "")
803+ require.Len(t, normal.mru, 1)
804+ require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU")
805+}
806+
767807 func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
768808 p, releasePTY := newBlockingPTY(t)
769809 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -12,7 +12,9 @@ import (
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/command"
15 "github.com/bnema/vev/internal/usecase/layout"16 "github.com/bnema/vev/internal/usecase/layout"
17+ "github.com/bnema/vev/internal/usecase/palette"
16 themeui "github.com/bnema/vev/internal/usecase/theme"18 themeui "github.com/bnema/vev/internal/usecase/theme"
17 "github.com/bnema/vev/pkg/renderer"19 "github.com/bnema/vev/pkg/renderer"
18 )20 )
@@ -764,6 +766,44 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
764 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))766 t.Fatalf("attention glyph not rendered in top bar: %q", rowText(frame.Row(0)))
765 }767 }
766 768
769+func TestBarStateForContextualRecentUsesSnapshotAndNormalUsesLiveMRU(t *testing.T) {
770+ p, releasePTY := newBlockingPTY(t)
771+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
772+ defer releasePTY()
773+ live := &session{id: "live", name: "live", tabs: []*tab{{}}}
774+ live.mruAt.Store(1)
775+ d.sessions[live.id] = live
776+
777+ hints := palette.ContextualHints{
778+ Kind: command.ContextHintRecentSessions,
779+ SelectedRank: 1,
780+ Recent: []palette.RecentSessionHint{{Rank: 1, Name: "captured"}},
781+ }
782+ // Hold the registry lock: a call to recentSessions would block here. The
783+ // contextual snapshot must still compose because it has no live MRU lookup.
784+ var contextual barState
785+ done := make(chan struct{})
786+ d.mu.Lock()
787+ go func() {
788+ contextual = d.barStateForPaletteHints(sess, "", &hints, []recentSession{{name: "captured", ephemeral: true, attention: true}})
789+ close(done)
790+ }()
791+ select {
792+ case <-done:
793+ case <-time.After(time.Second):
794+ d.mu.Unlock()
795+ t.Fatal("contextual composition consulted the live session registry")
796+ }
797+ d.mu.Unlock()
798+
799+ require.Empty(t, contextual.mru, "contextual composition must not read the live MRU")
800+ require.Equal(t, []rankedRecent{{rank: 1, name: "captured", ephemeral: true, attention: true, selected: true}}, contextual.rankedRecent)
801+
802+ normal := d.barStateFor(sess, "")
803+ require.Len(t, normal.mru, 1)
804+ require.Equal(t, "live", normal.mru[0].name, "normal composition retains canonical live MRU")
805+}
806+
767 func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {807 func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) {
768 p, releasePTY := newBlockingPTY(t)808 p, releasePTY := newBlockingPTY(t)
769 d, sess, _, _ := newManualSessionWithPTYs(t, p)809 d, sess, _, _ := newManualSessionWithPTYs(t, p)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close