fix(status): avoid live mru during contextual ranks
- commit
- 0ac83ce293342b295c5d26e71c1da8ca5ed7b9f9
- author
- brice · · 2026-07-11 12:38 UTC
- parent
- 611ca87
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 368 | 368 | preview = snapshotPickerPreview(overlays.previewTab) | |
| 369 | 369 | } | |
| 370 | 370 | 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) | |
| 375 | 374 | bars.theme = ac.getTheme() | |
| 376 | 375 | _, attentionVisible := pulseStyle(bars.attentionFrame) | |
| 377 | 376 | 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 consult | 372 | + | // 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 | |||
| 239 | 239 | 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 | 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 | 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 | 271 | state := barState{copyFeedback: copyFeedback} | |
| 248 | 272 | if d != nil { | |
| 249 | 273 | state.attentionFrame = d.attentionFrame() | |
| @@ -258,10 +282,6 @@ func (d *Daemon) barStateFor(cur *session, copyFeedback string) barState { | |||
| 258 | 282 | if d != nil { | |
| 259 | 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 | 285 | return state | |
| 266 | 286 | } | |
| 267 | 287 | ||
| @@ -239,11 +239,35 @@ func rankedRecentForHints(hints *palette.ContextualHints, recent []recentSession | |||||
| 239 | return entries | 239 | 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 state | 285 | return state | ||
| 266 | } | 286 | } | ||
| 267 | 287 | ||||
M internal/usecase/daemon/status_test.go +40 -0
| @@ -12,7 +12,9 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/command" | |
| 15 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/palette" | |
| 16 | 18 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 17 | 19 | "github.com/bnema/vev/pkg/renderer" | |
| 18 | 20 | ) | |
| @@ -764,6 +766,44 @@ func TestTopBarRendersAttentionBell(t *testing.T) { | |||
| 764 | 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 | 807 | func TestBarStateForMRUFreshestFirstCapCurrentExcludedAndAttention(t *testing.T) { | |
| 768 | 808 | p, releasePTY := newBlockingPTY(t) | |
| 769 | 809 | 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) | ||