detached 57a5dbb

feat(daemon): route user notices to per-client toast state

Add reportError/notify/NotifyGlobal on Daemon, a per-client toast stack on
overlayRuntime, and a pending-global drain on first paint.

reportError classifies via errors.As into *domain.UserError and falls back to
NoticeInternal, so an error is never silently dropped unless it is benign
(context.Canceled, errNoNeighbor, ports.ErrNoClipboardImage). Session-scoped
notices go to sess.client; global notices fan out over a two-level snapshot and
queue when nothing is attached.

noticeMu is innermost: it guards noticeToasts, noticeOverflow, and noticeSeq,
and every repaint is issued after it is released. Toasts coalesce by code plus
scope, cap at 3 newest-first with an overflow counter, and expire on d.clock at
4s/6s/8s by severity.
commit
57a5dbb833e587f9fb08a67d11378c537a7f57e2
author
brice · · 2026-07-20 12:25 UTC
parent
08a23ac
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +694 -0
M internal/usecase/daemon/client.go +6 -0
@@ -589,6 +589,12 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc
589589 // emits a full redraw. Attach must not wait for the resize-idle fallback
590590 // timer.
591591 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
592+ // Global notices raised while nothing was attached surface on this client.
593+ // Drained before the early return below so a session without an active tab
594+ // cannot swallow the queue.
595+ for _, n := range d.notices.drainPending() {
596+ d.showToast(ac, n)
597+ }
592598 tb := sess.activeTab()
593599 if tb == nil {
594600 return
@@ -589,6 +589,12 @@ func (d *Daemon) retireReplacedClient(old *attachedClient, cleanup renderLifecyc
589 // emits a full redraw. Attach must not wait for the resize-idle fallback589 // emits a full redraw. Attach must not wait for the resize-idle fallback
590 // timer.590 // timer.
591 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {591 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
592+ // Global notices raised while nothing was attached surface on this client.
593+ // Drained before the early return below so a session without an active tab
594+ // cannot swallow the queue.
595+ for _, n := range d.notices.drainPending() {
596+ d.showToast(ac, n)
597+ }
592 tb := sess.activeTab()598 tb := sess.activeTab()
593 if tb == nil {599 if tb == nil {
594 return600 return
M internal/usecase/daemon/daemon.go +2 -0
@@ -146,6 +146,7 @@ type Daemon struct {
146146 tabsConfig atomic.Pointer[domain.TabsConfig]
147147 themeConfig atomic.Pointer[themeConfigSnapshot]
148148 barScripts *barScriptState
149+ notices *noticeCenter
149150 resumeParkGrace time.Duration
150151 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
151152 // (see clipboard.go); empty means use os.TempDir().
@@ -350,6 +351,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
350351 animWake: make(chan struct{}, 1),
351352 snapshotMarshal: snapcodec.Marshal,
352353 snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
354+ notices: newNoticeCenter(),
353355 resumeParkGrace: defaultResumeParkGrace,
354356 barScripts: &barScriptState{
355357 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -146,6 +146,7 @@ type Daemon struct {
146 tabsConfig atomic.Pointer[domain.TabsConfig]146 tabsConfig atomic.Pointer[domain.TabsConfig]
147 themeConfig atomic.Pointer[themeConfigSnapshot]147 themeConfig atomic.Pointer[themeConfigSnapshot]
148 barScripts *barScriptState148 barScripts *barScriptState
149+ notices *noticeCenter
149 resumeParkGrace time.Duration150 resumeParkGrace time.Duration
150 // tempDir overrides os.TempDir() for clipboard-image-transfer writes151 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
151 // (see clipboard.go); empty means use os.TempDir().152 // (see clipboard.go); empty means use os.TempDir().
@@ -350,6 +351,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
350 animWake: make(chan struct{}, 1),351 animWake: make(chan struct{}, 1),
351 snapshotMarshal: snapcodec.Marshal,352 snapshotMarshal: snapcodec.Marshal,
352 snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),353 snapshotJobs: make(chan *snapshotCapture, snapshotQueueCapacity),
354+ notices: newNoticeCenter(),
353 resumeParkGrace: defaultResumeParkGrace,355 resumeParkGrace: defaultResumeParkGrace,
354 barScripts: &barScriptState{356 barScripts: &barScriptState{
355 cfg: barConfigFromDomain(domain.Defaults().Bar),357 cfg: barConfigFromDomain(domain.Defaults().Bar),
M internal/usecase/daemon/notify.go +212 -0
@@ -1,16 +1,28 @@
11 package daemon
22
33 import (
4+ "context"
5+ "errors"
6+ "strings"
47 "sync"
8+ "time"
59
610 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
712 )
813
914 const (
1015 noticeHistoryCap = 200
1116 noticePendingCap = 32
17+ // maxVisibleToasts bounds the stack drawn over a client's screen; anything
18+ // trimmed is counted in noticeOverflow and stays reachable in history.
19+ maxVisibleToasts = 3
1220 )
1321
22+// errNoNeighbor reports a directional focus move with nothing to move to. It is
23+// an ordinary outcome of navigation, never a user-facing failure.
24+var errNoNeighbor = errors.New("no pane in that direction")
25+
1426 // noticeCenter owns the daemon-wide notification history and the queue of
1527 // global notices awaiting a first attached client. Its mu is leaf-level: no
1628 // other lock is ever taken while holding it.
@@ -80,3 +92,203 @@ func (nc *noticeCenter) drainPending() []domain.Notification {
8092 nc.pending = nil
8193 return out
8294 }
95+
96+// noticeToast is one entry in a client's visible toast stack. seq identifies the
97+// entry across coalescing so a stale expiry timer removes only its own toast.
98+type noticeToast struct {
99+ n domain.Notification
100+ seq uint64
101+ timer pendingByteTimer
102+}
103+
104+// noticeTTL is how long a toast of this severity stays visible.
105+func noticeTTL(sev domain.NoticeSeverity) time.Duration {
106+ switch sev {
107+ case domain.NoticeInfo:
108+ return 4 * time.Second
109+ case domain.NoticeWarn:
110+ return 6 * time.Second
111+ default:
112+ return 8 * time.Second
113+ }
114+}
115+
116+// noticeDetails renders the full Unwrap chain for the yank/history views. The
117+// toast itself never shows this — only the UserError's own message.
118+func noticeDetails(err error) string {
119+ if err == nil {
120+ return ""
121+ }
122+ var parts []string
123+ for e := err; e != nil; e = errors.Unwrap(e) {
124+ parts = append(parts, e.Error())
125+ }
126+ return strings.Join(parts, " ← ")
127+}
128+
129+// benignNoticeError reports errors that are expected control flow rather than
130+// something the user needs to be told about.
131+func benignNoticeError(err error) bool {
132+ return errors.Is(err, context.Canceled) ||
133+ errors.Is(err, errNoNeighbor) ||
134+ errors.Is(err, ports.ErrNoClipboardImage)
135+}
136+
137+// reportError turns any error into a user-facing notice. Unclassified errors
138+// become NoticeInternal: an error reaching here is never silently dropped
139+// unless benignNoticeError says it is routine.
140+func (d *Daemon) reportError(sess *session, err error) {
141+ if err == nil || benignNoticeError(err) {
142+ return
143+ }
144+ var ue *domain.UserError
145+ if errors.As(err, &ue) {
146+ d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)
147+ return
148+ }
149+ d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
150+}
151+
152+// notify records a notice and routes it to whoever can see it. A nil sess means
153+// daemon-global: every attached client gets it, and if none is attached the
154+// notice waits in the pending queue for the next attach.
155+//
156+// Locking: sess.mu and d.mu are only held to snapshot; showToast is always
157+// called with no daemon, session, or pane lock held.
158+func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
159+ n := domain.Notification{
160+ Code: code,
161+ Severity: sev,
162+ Message: msg,
163+ Details: noticeDetails(cause),
164+ Time: d.clock.Now(),
165+ }
166+ if sess != nil {
167+ n.SessionID = sess.id
168+ }
169+ n = d.notices.record(n)
170+ d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
171+
172+ if sess != nil {
173+ sess.mu.Lock()
174+ ac := sess.client
175+ sess.mu.Unlock()
176+ if ac != nil {
177+ d.showToast(ac, n)
178+ }
179+ return
180+ }
181+
182+ d.mu.Lock()
183+ sessions := make([]*session, 0, len(d.sessions))
184+ for _, s := range d.sessions {
185+ sessions = append(sessions, s)
186+ }
187+ d.mu.Unlock()
188+
189+ delivered := false
190+ for _, s := range sessions {
191+ s.mu.Lock()
192+ ac := s.client
193+ s.mu.Unlock()
194+ if ac != nil {
195+ d.showToast(ac, n)
196+ delivered = true
197+ }
198+ }
199+ if !delivered {
200+ d.notices.queueGlobal(n)
201+ }
202+}
203+
204+// NotifyGlobal raises a daemon-wide notice from outside the daemon package.
205+func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
206+ d.notify(nil, sev, code, msg, cause)
207+}
208+
209+// showToast publishes a notice into one client's toast stack. An identical
210+// code within the same scope coalesces into the existing entry and restarts its
211+// TTL; otherwise the notice is prepended and the stack trimmed. The repaint is
212+// issued after noticeMu is released.
213+func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
214+ if ac == nil {
215+ return
216+ }
217+ ac.initOverlays()
218+ rt := ac.overlays
219+
220+ rt.noticeMu.Lock()
221+ if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {
222+ rt.noticeToasts[i].n.Count += n.Count
223+ rt.noticeToasts[i].n.Time = n.Time
224+ rt.noticeToasts[i].n.Details = n.Details
225+ rt.noticeToasts[i].timer.stop()
226+ d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
227+ rt.noticeMu.Unlock()
228+ d.repaintForNotice(ac)
229+ return
230+ }
231+
232+ rt.noticeSeq++
233+ toast := noticeToast{n: n, seq: rt.noticeSeq}
234+ rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...)
235+ if len(rt.noticeToasts) > maxVisibleToasts {
236+ for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ {
237+ rt.noticeToasts[i].timer.stop()
238+ rt.noticeOverflow++
239+ }
240+ rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
241+ }
242+ d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
243+ rt.noticeMu.Unlock()
244+ d.repaintForNotice(ac)
245+}
246+
247+// indexOfToastLocked finds a visible toast with the same code and scope.
248+// Callers must hold noticeMu.
249+func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int {
250+ for i := range rt.noticeToasts {
251+ if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid {
252+ return i
253+ }
254+ }
255+ return -1
256+}
257+
258+// retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
259+// timer goroutine re-acquires it and releases it before repainting, so the
260+// callback never runs with noticeMu held.
261+func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
262+ rt := ac.overlays
263+ rt.noticeSeq++
264+ t.seq = rt.noticeSeq
265+ seq := t.seq
266+ t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) {
267+ rt.noticeMu.Lock()
268+ kept := rt.noticeToasts[:0]
269+ for _, tt := range rt.noticeToasts {
270+ if tt.seq == seq {
271+ continue
272+ }
273+ kept = append(kept, tt)
274+ }
275+ rt.noticeToasts = kept
276+ if len(rt.noticeToasts) == 0 {
277+ rt.noticeOverflow = 0
278+ }
279+ rt.noticeMu.Unlock()
280+ d.repaintForNotice(ac)
281+ })
282+}
283+
284+// repaintForNotice asks for an urgent redraw. It must be called with noticeMu
285+// released: invalidateRender can paint inline, which takes sendMu.
286+func (d *Daemon) repaintForNotice(ac *attachedClient) {
287+ sess := ac.currentSession()
288+ if sess == nil {
289+ // Mid-handoff or torn down: whoever attaches next repaints anyway, and
290+ // invalidateRender cannot paint without a session.
291+ return
292+ }
293+ d.invalidateRender(sess, ac, true, "notify.go")
294+}
@@ -1,16 +1,28 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
5+ "errors"
6+ "strings"
4 "sync"7 "sync"
8+ "time"
5 9
6 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
7 )12 )
8 13
9 const (14 const (
10 noticeHistoryCap = 20015 noticeHistoryCap = 200
11 noticePendingCap = 3216 noticePendingCap = 32
17+ // maxVisibleToasts bounds the stack drawn over a client's screen; anything
18+ // trimmed is counted in noticeOverflow and stays reachable in history.
19+ maxVisibleToasts = 3
12 )20 )
13 21
22+// errNoNeighbor reports a directional focus move with nothing to move to. It is
23+// an ordinary outcome of navigation, never a user-facing failure.
24+var errNoNeighbor = errors.New("no pane in that direction")
25+
14 // noticeCenter owns the daemon-wide notification history and the queue of26 // noticeCenter owns the daemon-wide notification history and the queue of
15 // global notices awaiting a first attached client. Its mu is leaf-level: no27 // global notices awaiting a first attached client. Its mu is leaf-level: no
16 // other lock is ever taken while holding it.28 // other lock is ever taken while holding it.
@@ -80,3 +92,203 @@ func (nc *noticeCenter) drainPending() []domain.Notification {
80 nc.pending = nil92 nc.pending = nil
81 return out93 return out
82 }94 }
95+
96+// noticeToast is one entry in a client's visible toast stack. seq identifies the
97+// entry across coalescing so a stale expiry timer removes only its own toast.
98+type noticeToast struct {
99+ n domain.Notification
100+ seq uint64
101+ timer pendingByteTimer
102+}
103+
104+// noticeTTL is how long a toast of this severity stays visible.
105+func noticeTTL(sev domain.NoticeSeverity) time.Duration {
106+ switch sev {
107+ case domain.NoticeInfo:
108+ return 4 * time.Second
109+ case domain.NoticeWarn:
110+ return 6 * time.Second
111+ default:
112+ return 8 * time.Second
113+ }
114+}
115+
116+// noticeDetails renders the full Unwrap chain for the yank/history views. The
117+// toast itself never shows this — only the UserError's own message.
118+func noticeDetails(err error) string {
119+ if err == nil {
120+ return ""
121+ }
122+ var parts []string
123+ for e := err; e != nil; e = errors.Unwrap(e) {
124+ parts = append(parts, e.Error())
125+ }
126+ return strings.Join(parts, " ← ")
127+}
128+
129+// benignNoticeError reports errors that are expected control flow rather than
130+// something the user needs to be told about.
131+func benignNoticeError(err error) bool {
132+ return errors.Is(err, context.Canceled) ||
133+ errors.Is(err, errNoNeighbor) ||
134+ errors.Is(err, ports.ErrNoClipboardImage)
135+}
136+
137+// reportError turns any error into a user-facing notice. Unclassified errors
138+// become NoticeInternal: an error reaching here is never silently dropped
139+// unless benignNoticeError says it is routine.
140+func (d *Daemon) reportError(sess *session, err error) {
141+ if err == nil || benignNoticeError(err) {
142+ return
143+ }
144+ var ue *domain.UserError
145+ if errors.As(err, &ue) {
146+ d.notify(sess, ue.Severity, ue.Code, ue.Msg, ue.Err)
147+ return
148+ }
149+ d.notify(sess, domain.NoticeError, domain.NoticeInternal, "internal error", err)
150+}
151+
152+// notify records a notice and routes it to whoever can see it. A nil sess means
153+// daemon-global: every attached client gets it, and if none is attached the
154+// notice waits in the pending queue for the next attach.
155+//
156+// Locking: sess.mu and d.mu are only held to snapshot; showToast is always
157+// called with no daemon, session, or pane lock held.
158+func (d *Daemon) notify(sess *session, sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
159+ n := domain.Notification{
160+ Code: code,
161+ Severity: sev,
162+ Message: msg,
163+ Details: noticeDetails(cause),
164+ Time: d.clock.Now(),
165+ }
166+ if sess != nil {
167+ n.SessionID = sess.id
168+ }
169+ n = d.notices.record(n)
170+ d.log.Warn("user notice", "code", code.String(), "severity", sev, "msg", msg, "err", cause)
171+
172+ if sess != nil {
173+ sess.mu.Lock()
174+ ac := sess.client
175+ sess.mu.Unlock()
176+ if ac != nil {
177+ d.showToast(ac, n)
178+ }
179+ return
180+ }
181+
182+ d.mu.Lock()
183+ sessions := make([]*session, 0, len(d.sessions))
184+ for _, s := range d.sessions {
185+ sessions = append(sessions, s)
186+ }
187+ d.mu.Unlock()
188+
189+ delivered := false
190+ for _, s := range sessions {
191+ s.mu.Lock()
192+ ac := s.client
193+ s.mu.Unlock()
194+ if ac != nil {
195+ d.showToast(ac, n)
196+ delivered = true
197+ }
198+ }
199+ if !delivered {
200+ d.notices.queueGlobal(n)
201+ }
202+}
203+
204+// NotifyGlobal raises a daemon-wide notice from outside the daemon package.
205+func (d *Daemon) NotifyGlobal(sev domain.NoticeSeverity, code domain.NoticeCode, msg string, cause error) {
206+ d.notify(nil, sev, code, msg, cause)
207+}
208+
209+// showToast publishes a notice into one client's toast stack. An identical
210+// code within the same scope coalesces into the existing entry and restarts its
211+// TTL; otherwise the notice is prepended and the stack trimmed. The repaint is
212+// issued after noticeMu is released.
213+func (d *Daemon) showToast(ac *attachedClient, n domain.Notification) {
214+ if ac == nil {
215+ return
216+ }
217+ ac.initOverlays()
218+ rt := ac.overlays
219+
220+ rt.noticeMu.Lock()
221+ if i := rt.indexOfToastLocked(n.Code, n.SessionID); i >= 0 {
222+ rt.noticeToasts[i].n.Count += n.Count
223+ rt.noticeToasts[i].n.Time = n.Time
224+ rt.noticeToasts[i].n.Details = n.Details
225+ rt.noticeToasts[i].timer.stop()
226+ d.retainToastTimerLocked(ac, &rt.noticeToasts[i])
227+ rt.noticeMu.Unlock()
228+ d.repaintForNotice(ac)
229+ return
230+ }
231+
232+ rt.noticeSeq++
233+ toast := noticeToast{n: n, seq: rt.noticeSeq}
234+ rt.noticeToasts = append([]noticeToast{toast}, rt.noticeToasts...)
235+ if len(rt.noticeToasts) > maxVisibleToasts {
236+ for i := maxVisibleToasts; i < len(rt.noticeToasts); i++ {
237+ rt.noticeToasts[i].timer.stop()
238+ rt.noticeOverflow++
239+ }
240+ rt.noticeToasts = rt.noticeToasts[:maxVisibleToasts]
241+ }
242+ d.retainToastTimerLocked(ac, &rt.noticeToasts[0])
243+ rt.noticeMu.Unlock()
244+ d.repaintForNotice(ac)
245+}
246+
247+// indexOfToastLocked finds a visible toast with the same code and scope.
248+// Callers must hold noticeMu.
249+func (rt *overlayRuntime) indexOfToastLocked(code domain.NoticeCode, sid domain.SessionID) int {
250+ for i := range rt.noticeToasts {
251+ if rt.noticeToasts[i].n.Code == code && rt.noticeToasts[i].n.SessionID == sid {
252+ return i
253+ }
254+ }
255+ return -1
256+}
257+
258+// retainToastTimerLocked arms the toast's TTL. Callers must hold noticeMu; the
259+// timer goroutine re-acquires it and releases it before repainting, so the
260+// callback never runs with noticeMu held.
261+func (d *Daemon) retainToastTimerLocked(ac *attachedClient, t *noticeToast) {
262+ rt := ac.overlays
263+ rt.noticeSeq++
264+ t.seq = rt.noticeSeq
265+ seq := t.seq
266+ t.timer.retain(d.clock, noticeTTL(t.n.Severity), func(ports.Timer) {
267+ rt.noticeMu.Lock()
268+ kept := rt.noticeToasts[:0]
269+ for _, tt := range rt.noticeToasts {
270+ if tt.seq == seq {
271+ continue
272+ }
273+ kept = append(kept, tt)
274+ }
275+ rt.noticeToasts = kept
276+ if len(rt.noticeToasts) == 0 {
277+ rt.noticeOverflow = 0
278+ }
279+ rt.noticeMu.Unlock()
280+ d.repaintForNotice(ac)
281+ })
282+}
283+
284+// repaintForNotice asks for an urgent redraw. It must be called with noticeMu
285+// released: invalidateRender can paint inline, which takes sendMu.
286+func (d *Daemon) repaintForNotice(ac *attachedClient) {
287+ sess := ac.currentSession()
288+ if sess == nil {
289+ // Mid-handoff or torn down: whoever attaches next repaints anyway, and
290+ // invalidateRender cannot paint without a session.
291+ return
292+ }
293+ d.invalidateRender(sess, ac, true, "notify.go")
294+}
M internal/usecase/daemon/notify_test.go +464 -0
@@ -1,11 +1,18 @@
11 package daemon
22
33 import (
4+ "context"
5+ "errors"
46 "fmt"
7+ "sync"
8+ "sync/atomic"
59 "testing"
610 "time"
711
12+ "github.com/stretchr/testify/require"
13+
814 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
916 )
1017
1118 func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
@@ -109,3 +116,460 @@ func TestNoticeCenterPendingQueue(t *testing.T) {
109116 }
110117 })
111118 }
119+
120+// --- Task 3: routing, per-client toast state, attach drain ------------------
121+
122+// noticeClock hands out timers the test fires explicitly by advancing time.
123+// Toast TTLs are asserted from the durations recorded here, so the tests never
124+// depend on wall-clock progress.
125+type noticeClock struct {
126+ mu sync.Mutex
127+ now time.Time
128+ timers []*noticeTimer
129+}
130+
131+type noticeTimer struct {
132+ ch chan time.Time
133+ duration time.Duration
134+ deadline time.Time
135+ stopped atomic.Bool
136+}
137+
138+func newNoticeClock() *noticeClock {
139+ return &noticeClock{now: time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)}
140+}
141+
142+func (c *noticeClock) Now() time.Time {
143+ c.mu.Lock()
144+ defer c.mu.Unlock()
145+ return c.now
146+}
147+
148+func (c *noticeClock) NewTimer(d time.Duration) ports.Timer {
149+ c.mu.Lock()
150+ defer c.mu.Unlock()
151+ t := &noticeTimer{ch: make(chan time.Time, 1), duration: d, deadline: c.now.Add(d)}
152+ c.timers = append(c.timers, t)
153+ return t
154+}
155+
156+func (t *noticeTimer) C() <-chan time.Time { return t.ch }
157+
158+func (t *noticeTimer) Reset(time.Duration) bool { return false }
159+
160+func (t *noticeTimer) Stop() bool {
161+ t.stopped.Store(true)
162+ return true
163+}
164+
165+// advance moves the clock forward and fires every live timer that came due.
166+// Sends are non-blocking on a buffered channel, so no timer goroutine can wedge
167+// the test goroutine.
168+func (c *noticeClock) advance(d time.Duration) {
169+ c.mu.Lock()
170+ c.now = c.now.Add(d)
171+ due := make([]*noticeTimer, 0, len(c.timers))
172+ kept := make([]*noticeTimer, 0, len(c.timers))
173+ for _, t := range c.timers {
174+ if !t.stopped.Load() && !t.deadline.After(c.now) {
175+ t.stopped.Store(true)
176+ due = append(due, t)
177+ continue
178+ }
179+ kept = append(kept, t)
180+ }
181+ c.timers = kept
182+ now := c.now
183+ c.mu.Unlock()
184+ for _, t := range due {
185+ select {
186+ case t.ch <- now:
187+ default:
188+ }
189+ }
190+}
191+
192+// durations returns every TTL requested so far, in creation order.
193+func (c *noticeClock) durations() []time.Duration {
194+ c.mu.Lock()
195+ defer c.mu.Unlock()
196+ out := make([]time.Duration, 0, len(c.timers))
197+ for _, t := range c.timers {
198+ out = append(out, t.duration)
199+ }
200+ return out
201+}
202+
203+// newNoticeFixture builds a manual session owned by one attached client, with a
204+// caller-supplied clock so toast TTLs are deterministic. The session has no
205+// render coordinator, so invalidateRender paints directly and every repaint is
206+// observable as a frame on the returned channel.
207+func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attachedClient, chan ports.Frame) {
208+ t.Helper()
209+ p, _ := newBlockingPTY(t)
210+ d := newTestDaemon(t, nil, clk)
211+ tr, sends := newCapturingTransport(t)
212+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
213+ ac.initOverlays()
214+ sctx, cancel := context.WithCancel(d.serveCtx)
215+ wctx, wcancel := context.WithCancel(sctx)
216+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
217+ tb.ctx, tb.cancel = wctx, wcancel
218+ for _, pane := range tb.panes {
219+ pane.ctx, pane.cancel = wctx, wcancel
220+ }
221+ sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac}
222+ ac.setSession(sess)
223+ d.sessions[sess.id] = sess
224+ t.Cleanup(cancel)
225+ return d, sess, ac, sends
226+}
227+
228+// visibleToasts copies the client's toast state under noticeMu.
229+func visibleToasts(ac *attachedClient) ([]domain.Notification, int) {
230+ rt := ac.overlays
231+ rt.noticeMu.Lock()
232+ defer rt.noticeMu.Unlock()
233+ out := make([]domain.Notification, 0, len(rt.noticeToasts))
234+ for _, toast := range rt.noticeToasts {
235+ out = append(out, toast.n)
236+ }
237+ return out, rt.noticeOverflow
238+}
239+
240+// awaitToastCount waits for the toast slice to reach want. The polled condition
241+// only reads state; assertions stay on the test goroutine.
242+func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notification {
243+ t.Helper()
244+ require.Eventually(t, func() bool {
245+ ns, _ := visibleToasts(ac)
246+ return len(ns) == want
247+ }, 2*time.Second, time.Millisecond, "toast count never reached %d", want)
248+ got, _ := visibleToasts(ac)
249+ return got
250+}
251+
252+func drainFrames(sends chan ports.Frame) {
253+ for {
254+ select {
255+ case <-sends:
256+ default:
257+ return
258+ }
259+ }
260+}
261+
262+func TestReportErrorUserErrorBecomesNotification(t *testing.T) {
263+ tests := []struct {
264+ name string
265+ err *domain.UserError
266+ wantSev domain.NoticeSeverity
267+ }{
268+ {
269+ name: "error severity",
270+ err: domain.UserErr(domain.NoticePaneSpawn, "could not open pane", errors.New("fork/exec: no such file")),
271+ wantSev: domain.NoticeError,
272+ },
273+ {
274+ name: "warn severity",
275+ err: domain.UserWarn(domain.NoticeConfigReload, "config reload skipped", errors.New("parse: line 4")),
276+ wantSev: domain.NoticeWarn,
277+ },
278+ }
279+ for _, tc := range tests {
280+ t.Run(tc.name, func(t *testing.T) {
281+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
282+
283+ d.reportError(sess, tc.err)
284+
285+ hist := d.notices.history()
286+ require.Len(t, hist, 1)
287+ require.Equal(t, tc.err.Code, hist[0].Code)
288+ require.Equal(t, tc.wantSev, hist[0].Severity)
289+ require.Equal(t, tc.err.Msg, hist[0].Message)
290+ require.Equal(t, sess.id, hist[0].SessionID)
291+ require.Equal(t, 1, hist[0].Count)
292+ // Details carry the cause chain; the message never does.
293+ require.Contains(t, hist[0].Details, tc.err.Err.Error())
294+ require.NotContains(t, hist[0].Message, tc.err.Err.Error())
295+
296+ toasts := awaitToastCount(t, ac, 1)
297+ require.Equal(t, tc.err.Msg, toasts[0].Message)
298+ require.Equal(t, tc.err.Code, toasts[0].Code)
299+ })
300+ }
301+}
302+
303+func TestReportErrorDetailsJoinUnwrapChain(t *testing.T) {
304+ d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
305+ root := errors.New("permission denied")
306+ mid := fmt.Errorf("open /dev/pts/3: %w", root)
307+
308+ d.reportError(sess, domain.UserErr(domain.NoticePaneSpawn, "could not open pane", mid))
309+
310+ hist := d.notices.history()
311+ require.Len(t, hist, 1)
312+ require.Equal(t, mid.Error()+" ← "+root.Error(), hist[0].Details)
313+}
314+
315+func TestReportErrorUnknownErrorBecomesInternal(t *testing.T) {
316+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
317+ raw := errors.New("something unclassified broke")
318+
319+ d.reportError(sess, raw)
320+
321+ hist := d.notices.history()
322+ require.Len(t, hist, 1)
323+ require.Equal(t, domain.NoticeInternal, hist[0].Code)
324+ require.Equal(t, domain.NoticeError, hist[0].Severity)
325+ require.Equal(t, "internal error", hist[0].Message)
326+ require.Equal(t, raw.Error(), hist[0].Details)
327+
328+ toasts := awaitToastCount(t, ac, 1)
329+ require.Equal(t, "internal error", toasts[0].Message)
330+}
331+
332+func TestReportErrorBenignFiltered(t *testing.T) {
333+ tests := []struct {
334+ name string
335+ err error
336+ }{
337+ {name: "nil", err: nil},
338+ {name: "context canceled", err: context.Canceled},
339+ {name: "wrapped context canceled", err: fmt.Errorf("pane read: %w", context.Canceled)},
340+ {name: "no neighbor", err: errNoNeighbor},
341+ {name: "wrapped no neighbor", err: fmt.Errorf("focus: %w", errNoNeighbor)},
342+ {name: "no clipboard image", err: ports.ErrNoClipboardImage},
343+ {name: "wrapped no clipboard image", err: fmt.Errorf("yank: %w", ports.ErrNoClipboardImage)},
344+ }
345+ for _, tc := range tests {
346+ t.Run(tc.name, func(t *testing.T) {
347+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
348+
349+ d.reportError(sess, tc.err)
350+
351+ require.Empty(t, d.notices.history())
352+ toasts, _ := visibleToasts(ac)
353+ require.Empty(t, toasts)
354+ })
355+ }
356+}
357+
358+// TestReportErrorNonBenignIsNeverDropped guards the inverse of the filter: an
359+// error that merely reads like a sentinel must still reach history.
360+func TestReportErrorNonBenignIsNeverDropped(t *testing.T) {
361+ d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
362+
363+ d.reportError(sess, errors.New("no pane in that direction"))
364+
365+ require.Len(t, d.notices.history(), 1, "a look-alike error must not be filtered by message text")
366+}
367+
368+func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
369+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
370+ _, _, otherAC, _ := newNoticeFixture(t, newNoticeClock())
371+
372+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
373+
374+ toasts := awaitToastCount(t, ac, 1)
375+ require.Equal(t, sess.id, toasts[0].SessionID)
376+ otherToasts, _ := visibleToasts(otherAC)
377+ require.Empty(t, otherToasts, "another daemon's client must be untouched")
378+}
379+
380+func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) {
381+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
382+ sess.mu.Lock()
383+ sess.client = nil
384+ sess.mu.Unlock()
385+
386+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
387+
388+ require.Len(t, d.notices.history(), 1)
389+ toasts, _ := visibleToasts(ac)
390+ require.Empty(t, toasts, "a detached session must not paint a toast")
391+ require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals")
392+}
393+
394+func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
395+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
396+
397+ second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
398+ second.initOverlays()
399+ sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second}
400+ second.setSession(sess2)
401+ d.mu.Lock()
402+ d.sessions[sess2.id] = sess2
403+ d.mu.Unlock()
404+
405+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
406+
407+ first := awaitToastCount(t, ac, 1)
408+ require.Equal(t, domain.SessionID(""), first[0].SessionID, "global notices carry an empty session id")
409+ secondToasts := awaitToastCount(t, second, 1)
410+ require.Equal(t, domain.NoticeConfigReload, secondToasts[0].Code)
411+ require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued")
412+}
413+
414+func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
415+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
416+ sess.mu.Lock()
417+ sess.client = nil
418+ sess.mu.Unlock()
419+
420+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
421+
422+ require.Len(t, d.notices.history(), 1)
423+ toasts, _ := visibleToasts(ac)
424+ require.Empty(t, toasts, "no attached client means nothing to paint yet")
425+
426+ // Re-attach and let firstPaint drain the pending queue.
427+ sess.mu.Lock()
428+ sess.client = ac
429+ sess.mu.Unlock()
430+ d.firstPaint(sess, ac, domain.Size{})
431+
432+ drained := awaitToastCount(t, ac, 1)
433+ require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code)
434+ require.Equal(t, "snapshot write failed", drained[0].Message)
435+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
436+}
437+
438+func TestShowToastCoalesceAndTrim(t *testing.T) {
439+ t.Run("same code and scope coalesces", func(t *testing.T) {
440+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
441+
442+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
443+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
444+
445+ toasts := awaitToastCount(t, ac, 1)
446+ require.Equal(t, 2, toasts[0].Count)
447+ _, overflow := visibleToasts(ac)
448+ require.Zero(t, overflow)
449+ require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")
450+ })
451+
452+ t.Run("same code different scope does not coalesce", func(t *testing.T) {
453+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
454+
455+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
456+ d.notify(nil, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
457+
458+ toasts := awaitToastCount(t, ac, 2)
459+ require.Equal(t, 1, toasts[0].Count)
460+ require.Equal(t, 1, toasts[1].Count)
461+ })
462+
463+ t.Run("four distinct codes trim to three newest first", func(t *testing.T) {
464+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
465+ codes := []domain.NoticeCode{
466+ domain.NoticePaneSpawn,
467+ domain.NoticeTabSpawn,
468+ domain.NoticeSnapshotWrite,
469+ domain.NoticeClipboard,
470+ }
471+ for _, c := range codes {
472+ d.notify(sess, domain.NoticeError, c, c.String(), nil)
473+ }
474+
475+ toasts := awaitToastCount(t, ac, maxVisibleToasts)
476+ require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{
477+ toasts[0].Code, toasts[1].Code, toasts[2].Code,
478+ }, "newest first, oldest evicted")
479+ _, overflow := visibleToasts(ac)
480+ require.Equal(t, 1, overflow)
481+ })
482+}
483+
484+func TestNoticeTTLPerSeverity(t *testing.T) {
485+ tests := []struct {
486+ name string
487+ sev domain.NoticeSeverity
488+ want time.Duration
489+ }{
490+ {name: "info", sev: domain.NoticeInfo, want: 4 * time.Second},
491+ {name: "warn", sev: domain.NoticeWarn, want: 6 * time.Second},
492+ {name: "error", sev: domain.NoticeError, want: 8 * time.Second},
493+ }
494+ for _, tc := range tests {
495+ t.Run(tc.name, func(t *testing.T) {
496+ require.Equal(t, tc.want, noticeTTL(tc.sev))
497+
498+ clk := newNoticeClock()
499+ d, sess, ac, _ := newNoticeFixture(t, clk)
500+ d.notify(sess, tc.sev, domain.NoticeClipboard, "msg", nil)
501+ awaitToastCount(t, ac, 1)
502+ require.Equal(t, []time.Duration{tc.want}, clk.durations(), "the retained toast timer uses the severity TTL")
503+ })
504+ }
505+}
506+
507+func TestToastExpiresOnFakeClock(t *testing.T) {
508+ clk := newNoticeClock()
509+ d, sess, ac, sends := newNoticeFixture(t, clk)
510+
511+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
512+ awaitToastCount(t, ac, 1)
513+ drainFrames(sends)
514+
515+ // Short of the 8s error TTL nothing expires.
516+ clk.advance(7 * time.Second)
517+ toasts, _ := visibleToasts(ac)
518+ require.Len(t, toasts, 1, "toast must survive until its TTL elapses")
519+
520+ clk.advance(2 * time.Second)
521+ awaitToastCount(t, ac, 0)
522+ _, overflow := visibleToasts(ac)
523+ require.Zero(t, overflow, "overflow resets once no toast is visible")
524+
525+ select {
526+ case <-sends:
527+ case <-time.After(2 * time.Second):
528+ t.Fatal("expiry did not invalidate the render")
529+ }
530+}
531+
532+// TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
533+// toast that outlived it.
534+func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) {
535+ clk := newNoticeClock()
536+ d, sess, ac, _ := newNoticeFixture(t, clk)
537+
538+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) // 4s TTL
539+ awaitToastCount(t, ac, 1)
540+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "boom", nil) // 8s TTL
541+ awaitToastCount(t, ac, 2)
542+
543+ clk.advance(5 * time.Second)
544+
545+ toasts := awaitToastCount(t, ac, 1)
546+ require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed")
547+}
548+
549+// TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an
550+// expiry timer identifies its toast by entry sequence, not by code and scope.
551+// Coalescing re-arms the entry under a fresh sequence, so a timer that was
552+// stopped but had already slipped past its select cannot dismiss the refreshed
553+// toast that replaced the one it belonged to.
554+func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) {
555+ clk := newNoticeClock()
556+ d, sess, ac, _ := newNoticeFixture(t, clk)
557+
558+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
559+ awaitToastCount(t, ac, 1)
560+
561+ // Re-stamp the live entry's sequence, exactly as coalescing does, without
562+ // disturbing the already-armed timer. Its code and scope are unchanged.
563+ rt := ac.overlays
564+ rt.noticeMu.Lock()
565+ rt.noticeSeq++
566+ rt.noticeToasts[0].seq = rt.noticeSeq
567+ rt.noticeMu.Unlock()
568+
569+ clk.advance(4 * time.Second)
570+
571+ require.Never(t, func() bool {
572+ ns, _ := visibleToasts(ac)
573+ return len(ns) == 0
574+ }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed")
575+}
@@ -1,11 +1,18 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
5+ "errors"
4 "fmt"6 "fmt"
7+ "sync"
8+ "sync/atomic"
5 "testing"9 "testing"
6 "time"10 "time"
7 11
12+ "github.com/stretchr/testify/require"
13+
8 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
9 )16 )
10 17
11 func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {18 func notice(code domain.NoticeCode, msg string, sid domain.SessionID) domain.Notification {
@@ -109,3 +116,460 @@ func TestNoticeCenterPendingQueue(t *testing.T) {
109 }116 }
110 })117 })
111 }118 }
119+
120+// --- Task 3: routing, per-client toast state, attach drain ------------------
121+
122+// noticeClock hands out timers the test fires explicitly by advancing time.
123+// Toast TTLs are asserted from the durations recorded here, so the tests never
124+// depend on wall-clock progress.
125+type noticeClock struct {
126+ mu sync.Mutex
127+ now time.Time
128+ timers []*noticeTimer
129+}
130+
131+type noticeTimer struct {
132+ ch chan time.Time
133+ duration time.Duration
134+ deadline time.Time
135+ stopped atomic.Bool
136+}
137+
138+func newNoticeClock() *noticeClock {
139+ return &noticeClock{now: time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)}
140+}
141+
142+func (c *noticeClock) Now() time.Time {
143+ c.mu.Lock()
144+ defer c.mu.Unlock()
145+ return c.now
146+}
147+
148+func (c *noticeClock) NewTimer(d time.Duration) ports.Timer {
149+ c.mu.Lock()
150+ defer c.mu.Unlock()
151+ t := &noticeTimer{ch: make(chan time.Time, 1), duration: d, deadline: c.now.Add(d)}
152+ c.timers = append(c.timers, t)
153+ return t
154+}
155+
156+func (t *noticeTimer) C() <-chan time.Time { return t.ch }
157+
158+func (t *noticeTimer) Reset(time.Duration) bool { return false }
159+
160+func (t *noticeTimer) Stop() bool {
161+ t.stopped.Store(true)
162+ return true
163+}
164+
165+// advance moves the clock forward and fires every live timer that came due.
166+// Sends are non-blocking on a buffered channel, so no timer goroutine can wedge
167+// the test goroutine.
168+func (c *noticeClock) advance(d time.Duration) {
169+ c.mu.Lock()
170+ c.now = c.now.Add(d)
171+ due := make([]*noticeTimer, 0, len(c.timers))
172+ kept := make([]*noticeTimer, 0, len(c.timers))
173+ for _, t := range c.timers {
174+ if !t.stopped.Load() && !t.deadline.After(c.now) {
175+ t.stopped.Store(true)
176+ due = append(due, t)
177+ continue
178+ }
179+ kept = append(kept, t)
180+ }
181+ c.timers = kept
182+ now := c.now
183+ c.mu.Unlock()
184+ for _, t := range due {
185+ select {
186+ case t.ch <- now:
187+ default:
188+ }
189+ }
190+}
191+
192+// durations returns every TTL requested so far, in creation order.
193+func (c *noticeClock) durations() []time.Duration {
194+ c.mu.Lock()
195+ defer c.mu.Unlock()
196+ out := make([]time.Duration, 0, len(c.timers))
197+ for _, t := range c.timers {
198+ out = append(out, t.duration)
199+ }
200+ return out
201+}
202+
203+// newNoticeFixture builds a manual session owned by one attached client, with a
204+// caller-supplied clock so toast TTLs are deterministic. The session has no
205+// render coordinator, so invalidateRender paints directly and every repaint is
206+// observable as a frame on the returned channel.
207+func newNoticeFixture(t *testing.T, clk ports.Clock) (*Daemon, *session, *attachedClient, chan ports.Frame) {
208+ t.Helper()
209+ p, _ := newBlockingPTY(t)
210+ d := newTestDaemon(t, nil, clk)
211+ tr, sends := newCapturingTransport(t)
212+ ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
213+ ac.initOverlays()
214+ sctx, cancel := context.WithCancel(d.serveCtx)
215+ wctx, wcancel := context.WithCancel(sctx)
216+ tb := newTab(p, domain.Size{Cols: 80, Rows: 23})
217+ tb.ctx, tb.cancel = wctx, wcancel
218+ for _, pane := range tb.panes {
219+ pane.ctx, pane.cancel = wctx, wcancel
220+ }
221+ sess := &session{id: "manual", name: "work", ctx: sctx, cancel: cancel, tabs: []*tab{tb}, client: ac}
222+ ac.setSession(sess)
223+ d.sessions[sess.id] = sess
224+ t.Cleanup(cancel)
225+ return d, sess, ac, sends
226+}
227+
228+// visibleToasts copies the client's toast state under noticeMu.
229+func visibleToasts(ac *attachedClient) ([]domain.Notification, int) {
230+ rt := ac.overlays
231+ rt.noticeMu.Lock()
232+ defer rt.noticeMu.Unlock()
233+ out := make([]domain.Notification, 0, len(rt.noticeToasts))
234+ for _, toast := range rt.noticeToasts {
235+ out = append(out, toast.n)
236+ }
237+ return out, rt.noticeOverflow
238+}
239+
240+// awaitToastCount waits for the toast slice to reach want. The polled condition
241+// only reads state; assertions stay on the test goroutine.
242+func awaitToastCount(t *testing.T, ac *attachedClient, want int) []domain.Notification {
243+ t.Helper()
244+ require.Eventually(t, func() bool {
245+ ns, _ := visibleToasts(ac)
246+ return len(ns) == want
247+ }, 2*time.Second, time.Millisecond, "toast count never reached %d", want)
248+ got, _ := visibleToasts(ac)
249+ return got
250+}
251+
252+func drainFrames(sends chan ports.Frame) {
253+ for {
254+ select {
255+ case <-sends:
256+ default:
257+ return
258+ }
259+ }
260+}
261+
262+func TestReportErrorUserErrorBecomesNotification(t *testing.T) {
263+ tests := []struct {
264+ name string
265+ err *domain.UserError
266+ wantSev domain.NoticeSeverity
267+ }{
268+ {
269+ name: "error severity",
270+ err: domain.UserErr(domain.NoticePaneSpawn, "could not open pane", errors.New("fork/exec: no such file")),
271+ wantSev: domain.NoticeError,
272+ },
273+ {
274+ name: "warn severity",
275+ err: domain.UserWarn(domain.NoticeConfigReload, "config reload skipped", errors.New("parse: line 4")),
276+ wantSev: domain.NoticeWarn,
277+ },
278+ }
279+ for _, tc := range tests {
280+ t.Run(tc.name, func(t *testing.T) {
281+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
282+
283+ d.reportError(sess, tc.err)
284+
285+ hist := d.notices.history()
286+ require.Len(t, hist, 1)
287+ require.Equal(t, tc.err.Code, hist[0].Code)
288+ require.Equal(t, tc.wantSev, hist[0].Severity)
289+ require.Equal(t, tc.err.Msg, hist[0].Message)
290+ require.Equal(t, sess.id, hist[0].SessionID)
291+ require.Equal(t, 1, hist[0].Count)
292+ // Details carry the cause chain; the message never does.
293+ require.Contains(t, hist[0].Details, tc.err.Err.Error())
294+ require.NotContains(t, hist[0].Message, tc.err.Err.Error())
295+
296+ toasts := awaitToastCount(t, ac, 1)
297+ require.Equal(t, tc.err.Msg, toasts[0].Message)
298+ require.Equal(t, tc.err.Code, toasts[0].Code)
299+ })
300+ }
301+}
302+
303+func TestReportErrorDetailsJoinUnwrapChain(t *testing.T) {
304+ d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
305+ root := errors.New("permission denied")
306+ mid := fmt.Errorf("open /dev/pts/3: %w", root)
307+
308+ d.reportError(sess, domain.UserErr(domain.NoticePaneSpawn, "could not open pane", mid))
309+
310+ hist := d.notices.history()
311+ require.Len(t, hist, 1)
312+ require.Equal(t, mid.Error()+" ← "+root.Error(), hist[0].Details)
313+}
314+
315+func TestReportErrorUnknownErrorBecomesInternal(t *testing.T) {
316+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
317+ raw := errors.New("something unclassified broke")
318+
319+ d.reportError(sess, raw)
320+
321+ hist := d.notices.history()
322+ require.Len(t, hist, 1)
323+ require.Equal(t, domain.NoticeInternal, hist[0].Code)
324+ require.Equal(t, domain.NoticeError, hist[0].Severity)
325+ require.Equal(t, "internal error", hist[0].Message)
326+ require.Equal(t, raw.Error(), hist[0].Details)
327+
328+ toasts := awaitToastCount(t, ac, 1)
329+ require.Equal(t, "internal error", toasts[0].Message)
330+}
331+
332+func TestReportErrorBenignFiltered(t *testing.T) {
333+ tests := []struct {
334+ name string
335+ err error
336+ }{
337+ {name: "nil", err: nil},
338+ {name: "context canceled", err: context.Canceled},
339+ {name: "wrapped context canceled", err: fmt.Errorf("pane read: %w", context.Canceled)},
340+ {name: "no neighbor", err: errNoNeighbor},
341+ {name: "wrapped no neighbor", err: fmt.Errorf("focus: %w", errNoNeighbor)},
342+ {name: "no clipboard image", err: ports.ErrNoClipboardImage},
343+ {name: "wrapped no clipboard image", err: fmt.Errorf("yank: %w", ports.ErrNoClipboardImage)},
344+ }
345+ for _, tc := range tests {
346+ t.Run(tc.name, func(t *testing.T) {
347+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
348+
349+ d.reportError(sess, tc.err)
350+
351+ require.Empty(t, d.notices.history())
352+ toasts, _ := visibleToasts(ac)
353+ require.Empty(t, toasts)
354+ })
355+ }
356+}
357+
358+// TestReportErrorNonBenignIsNeverDropped guards the inverse of the filter: an
359+// error that merely reads like a sentinel must still reach history.
360+func TestReportErrorNonBenignIsNeverDropped(t *testing.T) {
361+ d, sess, _, _ := newNoticeFixture(t, newNoticeClock())
362+
363+ d.reportError(sess, errors.New("no pane in that direction"))
364+
365+ require.Len(t, d.notices.history(), 1, "a look-alike error must not be filtered by message text")
366+}
367+
368+func TestNotifyRoutesToSessionClientOnly(t *testing.T) {
369+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
370+ _, _, otherAC, _ := newNoticeFixture(t, newNoticeClock())
371+
372+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
373+
374+ toasts := awaitToastCount(t, ac, 1)
375+ require.Equal(t, sess.id, toasts[0].SessionID)
376+ otherToasts, _ := visibleToasts(otherAC)
377+ require.Empty(t, otherToasts, "another daemon's client must be untouched")
378+}
379+
380+func TestNotifySessionWithoutClientRecordsHistoryOnly(t *testing.T) {
381+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
382+ sess.mu.Lock()
383+ sess.client = nil
384+ sess.mu.Unlock()
385+
386+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
387+
388+ require.Len(t, d.notices.history(), 1)
389+ toasts, _ := visibleToasts(ac)
390+ require.Empty(t, toasts, "a detached session must not paint a toast")
391+ require.Empty(t, d.notices.drainPending(), "session-scoped notices are never queued as pending globals")
392+}
393+
394+func TestNotifyGlobalFansOutToAttachedClients(t *testing.T) {
395+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
396+
397+ second := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}}
398+ second.initOverlays()
399+ sess2 := &session{id: "manual-2", name: "other", ctx: sess.ctx, cancel: func() {}, client: second}
400+ second.setSession(sess2)
401+ d.mu.Lock()
402+ d.sessions[sess2.id] = sess2
403+ d.mu.Unlock()
404+
405+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeConfigReload, "config reload failed", nil)
406+
407+ first := awaitToastCount(t, ac, 1)
408+ require.Equal(t, domain.SessionID(""), first[0].SessionID, "global notices carry an empty session id")
409+ secondToasts := awaitToastCount(t, second, 1)
410+ require.Equal(t, domain.NoticeConfigReload, secondToasts[0].Code)
411+ require.Empty(t, d.notices.drainPending(), "delivered globals must not also be queued")
412+}
413+
414+func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
415+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
416+ sess.mu.Lock()
417+ sess.client = nil
418+ sess.mu.Unlock()
419+
420+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotWrite, "snapshot write failed", nil)
421+
422+ require.Len(t, d.notices.history(), 1)
423+ toasts, _ := visibleToasts(ac)
424+ require.Empty(t, toasts, "no attached client means nothing to paint yet")
425+
426+ // Re-attach and let firstPaint drain the pending queue.
427+ sess.mu.Lock()
428+ sess.client = ac
429+ sess.mu.Unlock()
430+ d.firstPaint(sess, ac, domain.Size{})
431+
432+ drained := awaitToastCount(t, ac, 1)
433+ require.Equal(t, domain.NoticeSnapshotWrite, drained[0].Code)
434+ require.Equal(t, "snapshot write failed", drained[0].Message)
435+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
436+}
437+
438+func TestShowToastCoalesceAndTrim(t *testing.T) {
439+ t.Run("same code and scope coalesces", func(t *testing.T) {
440+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
441+
442+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
443+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
444+
445+ toasts := awaitToastCount(t, ac, 1)
446+ require.Equal(t, 2, toasts[0].Count)
447+ _, overflow := visibleToasts(ac)
448+ require.Zero(t, overflow)
449+ require.Len(t, d.notices.history(), 2, "coalescing is display-only; history keeps both")
450+ })
451+
452+ t.Run("same code different scope does not coalesce", func(t *testing.T) {
453+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
454+
455+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
456+ d.notify(nil, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
457+
458+ toasts := awaitToastCount(t, ac, 2)
459+ require.Equal(t, 1, toasts[0].Count)
460+ require.Equal(t, 1, toasts[1].Count)
461+ })
462+
463+ t.Run("four distinct codes trim to three newest first", func(t *testing.T) {
464+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
465+ codes := []domain.NoticeCode{
466+ domain.NoticePaneSpawn,
467+ domain.NoticeTabSpawn,
468+ domain.NoticeSnapshotWrite,
469+ domain.NoticeClipboard,
470+ }
471+ for _, c := range codes {
472+ d.notify(sess, domain.NoticeError, c, c.String(), nil)
473+ }
474+
475+ toasts := awaitToastCount(t, ac, maxVisibleToasts)
476+ require.Equal(t, []domain.NoticeCode{codes[3], codes[2], codes[1]}, []domain.NoticeCode{
477+ toasts[0].Code, toasts[1].Code, toasts[2].Code,
478+ }, "newest first, oldest evicted")
479+ _, overflow := visibleToasts(ac)
480+ require.Equal(t, 1, overflow)
481+ })
482+}
483+
484+func TestNoticeTTLPerSeverity(t *testing.T) {
485+ tests := []struct {
486+ name string
487+ sev domain.NoticeSeverity
488+ want time.Duration
489+ }{
490+ {name: "info", sev: domain.NoticeInfo, want: 4 * time.Second},
491+ {name: "warn", sev: domain.NoticeWarn, want: 6 * time.Second},
492+ {name: "error", sev: domain.NoticeError, want: 8 * time.Second},
493+ }
494+ for _, tc := range tests {
495+ t.Run(tc.name, func(t *testing.T) {
496+ require.Equal(t, tc.want, noticeTTL(tc.sev))
497+
498+ clk := newNoticeClock()
499+ d, sess, ac, _ := newNoticeFixture(t, clk)
500+ d.notify(sess, tc.sev, domain.NoticeClipboard, "msg", nil)
501+ awaitToastCount(t, ac, 1)
502+ require.Equal(t, []time.Duration{tc.want}, clk.durations(), "the retained toast timer uses the severity TTL")
503+ })
504+ }
505+}
506+
507+func TestToastExpiresOnFakeClock(t *testing.T) {
508+ clk := newNoticeClock()
509+ d, sess, ac, sends := newNoticeFixture(t, clk)
510+
511+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "could not open pane", nil)
512+ awaitToastCount(t, ac, 1)
513+ drainFrames(sends)
514+
515+ // Short of the 8s error TTL nothing expires.
516+ clk.advance(7 * time.Second)
517+ toasts, _ := visibleToasts(ac)
518+ require.Len(t, toasts, 1, "toast must survive until its TTL elapses")
519+
520+ clk.advance(2 * time.Second)
521+ awaitToastCount(t, ac, 0)
522+ _, overflow := visibleToasts(ac)
523+ require.Zero(t, overflow, "overflow resets once no toast is visible")
524+
525+ select {
526+ case <-sends:
527+ case <-time.After(2 * time.Second):
528+ t.Fatal("expiry did not invalidate the render")
529+ }
530+}
531+
532+// TestToastExpiryOnlyRemovesItsOwnEntry proves a fired timer cannot dismiss a
533+// toast that outlived it.
534+func TestToastExpiryOnlyRemovesItsOwnEntry(t *testing.T) {
535+ clk := newNoticeClock()
536+ d, sess, ac, _ := newNoticeFixture(t, clk)
537+
538+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil) // 4s TTL
539+ awaitToastCount(t, ac, 1)
540+ d.notify(sess, domain.NoticeError, domain.NoticePaneSpawn, "boom", nil) // 8s TTL
541+ awaitToastCount(t, ac, 2)
542+
543+ clk.advance(5 * time.Second)
544+
545+ toasts := awaitToastCount(t, ac, 1)
546+ require.Equal(t, domain.NoticePaneSpawn, toasts[0].Code, "only the expired info toast is removed")
547+}
548+
549+// TestToastExpiryRemovesOnlyTheEntryItArmed pins the removal predicate: an
550+// expiry timer identifies its toast by entry sequence, not by code and scope.
551+// Coalescing re-arms the entry under a fresh sequence, so a timer that was
552+// stopped but had already slipped past its select cannot dismiss the refreshed
553+// toast that replaced the one it belonged to.
554+func TestToastExpiryRemovesOnlyTheEntryItArmed(t *testing.T) {
555+ clk := newNoticeClock()
556+ d, sess, ac, _ := newNoticeFixture(t, clk)
557+
558+ d.notify(sess, domain.NoticeInfo, domain.NoticeClipboard, "copied", nil)
559+ awaitToastCount(t, ac, 1)
560+
561+ // Re-stamp the live entry's sequence, exactly as coalescing does, without
562+ // disturbing the already-armed timer. Its code and scope are unchanged.
563+ rt := ac.overlays
564+ rt.noticeMu.Lock()
565+ rt.noticeSeq++
566+ rt.noticeToasts[0].seq = rt.noticeSeq
567+ rt.noticeMu.Unlock()
568+
569+ clk.advance(4 * time.Second)
570+
571+ require.Never(t, func() bool {
572+ ns, _ := visibleToasts(ac)
573+ return len(ns) == 0
574+ }, 200*time.Millisecond, 5*time.Millisecond, "expiry must remove only the entry it armed")
575+}
M internal/usecase/daemon/overlay_runtime.go +10 -0
@@ -48,6 +48,16 @@ type overlayRuntime struct {
4848 copyPointer copyPointerState
4949 copyClick copyClickCandidate
5050 copyPointerEpoch uint64
51+
52+ // noticeMu is the innermost overlay lock: it guards the toast fields below
53+ // and nothing is ever locked, sent, or rendered while it is held. The only
54+ // permitted nesting is sendMu -> noticeMu (rendering reads the toasts).
55+ noticeMu sync.Mutex
56+ noticeToasts []noticeToast
57+ noticeOverflow int
58+ // noticeSeq numbers toast entries so an already-fired expiry timer cannot
59+ // dismiss the refreshed entry that replaced the one it belonged to.
60+ noticeSeq uint64
5161 }
5262
5363 type copyPointerState struct {
@@ -48,6 +48,16 @@ type overlayRuntime struct {
48 copyPointer copyPointerState48 copyPointer copyPointerState
49 copyClick copyClickCandidate49 copyClick copyClickCandidate
50 copyPointerEpoch uint6450 copyPointerEpoch uint64
51+
52+ // noticeMu is the innermost overlay lock: it guards the toast fields below
53+ // and nothing is ever locked, sent, or rendered while it is held. The only
54+ // permitted nesting is sendMu -> noticeMu (rendering reads the toasts).
55+ noticeMu sync.Mutex
56+ noticeToasts []noticeToast
57+ noticeOverflow int
58+ // noticeSeq numbers toast entries so an already-fired expiry timer cannot
59+ // dismiss the refreshed entry that replaced the one it belonged to.
60+ noticeSeq uint64
51 }61 }
52 62
53 type copyPointerState struct {63 type copyPointerState struct {

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 12

Tags 11

↑↓ moveenter openesc close