fix(daemon): gate mouse input on notices overlay activity
A click while the notifications history modal was open fell through to floating-pane hit-testing, title-bar focus changes, and terminal mouse forwarding, because handleMouse's overlay guard omitted the notices overlay. The modal has no mouse handler of its own, so the pane behind it would silently react to clicks on the modal.
- commit
- 211b2e1117de537435e89bf8b6185e19496284ff
- author
- brice · · 2026-07-20 13:40 UTC
- parent
- 1a97012
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +36 -1
M internal/usecase/daemon/input.go +1 -1
| @@ -35,7 +35,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 35 | 35 | frameEvent := ev | |
| 36 | 36 | ac.initOverlays() | |
| 37 | 37 | rt := ac.overlays | |
| 38 | - | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() { | |
| 38 | + | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() { | |
| 39 | 39 | return | |
| 40 | 40 | } | |
| 41 | 41 | sess := ac.currentSession() | |
| @@ -35,7 +35,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 35 | frameEvent := ev | 35 | frameEvent := ev | ||
| 36 | ac.initOverlays() | 36 | ac.initOverlays() | ||
| 37 | rt := ac.overlays | 37 | rt := ac.overlays | ||
| 38 | - | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() { | 38 | + | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() { |
| 39 | return | 39 | return | ||
| 40 | } | 40 | } | ||
| 41 | sess := ac.currentSession() | 41 | sess := ac.currentSession() | ||
M internal/usecase/daemon/input_test.go +35 -0
| @@ -1444,6 +1444,41 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) { | |||
| 1444 | 1444 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 1445 | 1445 | } | |
| 1446 | 1446 | ||
| 1447 | + | // TestMouseGatedWhileNoticesOverlayActive guards against a click on the | |
| 1448 | + | // notifications modal falling through to pane hit-testing underneath it: the | |
| 1449 | + | // modal has no mouse handler of its own, so without the gate a click would | |
| 1450 | + | // silently retarget focus and forward SGR bytes to whichever pane sits under | |
| 1451 | + | // the modal's coordinates. | |
| 1452 | + | func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) { | |
| 1453 | + | p1 := portsmocks.NewMockPTY(t) | |
| 1454 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |
| 1455 | + | p2 := portsmocks.NewMockPTY(t) | |
| 1456 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |
| 1457 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | |
| 1458 | + | d.procComm = nil | |
| 1459 | + | tb := sess.activeTab() | |
| 1460 | + | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |
| 1461 | + | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 1462 | + | tb.mu.Lock() | |
| 1463 | + | tb.size = domain.Size{Cols: 41, Rows: 5} | |
| 1464 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |
| 1465 | + | tb.tree.Focus = "pane-1" | |
| 1466 | + | tb.panes["pane-2"] = p2pane | |
| 1467 | + | tb.mu.Unlock() | |
| 1468 | + | ||
| 1469 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |
| 1470 | + | d.enterNotices(sess, ac) | |
| 1471 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 1472 | + | require.True(t, ac.overlays.noticesActive()) | |
| 1473 | + | ||
| 1474 | + | // Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns | |
| 1475 | + | // proves would otherwise focus pane-2 and forward the SGR report to it. | |
| 1476 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1477 | + | ||
| 1478 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open") | |
| 1479 | + | require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay") | |
| 1480 | + | } | |
| 1481 | + | ||
| 1447 | 1482 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |
| 1448 | 1483 | p1 := portsmocks.NewMockPTY(t) | |
| 1449 | 1484 | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | |
| @@ -1444,6 +1444,41 @@ func TestMouseHitTestFocusesPaneAndTranslatesSGRColumns(t *testing.T) { | |||||
| 1444 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | 1444 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | ||
| 1445 | } | 1445 | } | ||
| 1446 | 1446 | ||||
| 1447 | + | // TestMouseGatedWhileNoticesOverlayActive guards against a click on the | |||
| 1448 | + | // notifications modal falling through to pane hit-testing underneath it: the | |||
| 1449 | + | // modal has no mouse handler of its own, so without the gate a click would | |||
| 1450 | + | // silently retarget focus and forward SGR bytes to whichever pane sits under | |||
| 1451 | + | // the modal's coordinates. | |||
| 1452 | + | func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) { | |||
| 1453 | + | p1 := portsmocks.NewMockPTY(t) | |||
| 1454 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |||
| 1455 | + | p2 := portsmocks.NewMockPTY(t) | |||
| 1456 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 5}).Return(nil).Maybe() | |||
| 1457 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p1) | |||
| 1458 | + | d.procComm = nil | |||
| 1459 | + | tb := sess.activeTab() | |||
| 1460 | + | p2pane := newPane("pane-2", p2, domain.Size{Cols: 20, Rows: 5}) | |||
| 1461 | + | p2pane.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 1462 | + | tb.mu.Lock() | |||
| 1463 | + | tb.size = domain.Size{Cols: 41, Rows: 5} | |||
| 1464 | + | tb.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}} | |||
| 1465 | + | tb.tree.Focus = "pane-1" | |||
| 1466 | + | tb.panes["pane-2"] = p2pane | |||
| 1467 | + | tb.mu.Unlock() | |||
| 1468 | + | ||||
| 1469 | + | d.notices.record(domain.Notification{Code: domain.NoticePaneSpawn, Message: "m", Time: time.Unix(1, 0)}) | |||
| 1470 | + | d.enterNotices(sess, ac) | |||
| 1471 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 1472 | + | require.True(t, ac.overlays.noticesActive()) | |||
| 1473 | + | ||||
| 1474 | + | // Same coordinates that TestMouseHitTestFocusesPaneAndTranslatesSGRColumns | |||
| 1475 | + | // proves would otherwise focus pane-2 and forward the SGR report to it. | |||
| 1476 | + | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |||
| 1477 | + | ||||
| 1478 | + | require.Equal(t, layout.PaneID("pane-1"), tb.tree.Focus, "mouse must not reach pane hit-testing while the notices overlay is open") | |||
| 1479 | + | require.True(t, ac.overlays.noticesActive(), "mouse click must not close the notices overlay") | |||
| 1480 | + | } | |||
| 1481 | + | ||||
| 1447 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | 1482 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | ||
| 1448 | p1 := portsmocks.NewMockPTY(t) | 1483 | p1 := portsmocks.NewMockPTY(t) | ||
| 1449 | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | 1484 | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | ||