feat(notification): cancel on chat interaction

Amolith and Crush created

Prevent completion notifications from showing when users are actively
interacting with the chat interface (typing, clicking, scrolling,
opening dialogs).

Co-Authored-By: Crush <crush@charm.land>

Change summary

internal/agent/agent.go                       | 20 +++++++++++++
internal/agent/coordinator.go                 | 22 ++++++++++++++
internal/tui/components/chat/chat.go          | 12 ++++++++
internal/tui/components/chat/editor/editor.go | 11 +++++++
internal/tui/page/chat/chat.go                | 31 +++++++++++++++++++++
internal/tui/tui.go                           | 15 +++++++++
6 files changed, 110 insertions(+), 1 deletion(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -59,6 +59,12 @@ type SessionAgent interface {
 	ClearQueue(sessionID string)
 	Summarize(context.Context, string, fantasy.ProviderOptions) error
 	Model() Model
+	// CancelCompletionNotification cancels any scheduled "turn ended"
+	// notification for the provided sessionID.
+	CancelCompletionNotification(sessionID string)
+	// HasPendingCompletionNotification returns true if a turn-end
+	// notification has been scheduled and not yet cancelled/shown.
+	HasPendingCompletionNotification(sessionID string) bool
 }
 
 const completionNotificationDelay = 5 * time.Second
@@ -546,6 +552,20 @@ func (a *sessionAgent) cancelCompletionNotification(sessionID string) {
 	}
 }
 
+// CancelCompletionNotification implements SessionAgent.
+func (a *sessionAgent) CancelCompletionNotification(sessionID string) {
+	a.cancelCompletionNotification(sessionID)
+}
+
+// HasPendingCompletionNotification implements SessionAgent.
+func (a *sessionAgent) HasPendingCompletionNotification(sessionID string) bool {
+	if a.IsSessionBusy(sessionID) {
+		return false
+	}
+	_, ok := a.completionCancels.Get(sessionID)
+	return ok
+}
+
 func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
 	if a.IsSessionBusy(sessionID) {
 		return ErrSessionBusy

internal/agent/coordinator.go 🔗

@@ -53,6 +53,12 @@ type Coordinator interface {
 	Summarize(context.Context, string) error
 	Model() Model
 	UpdateModels(ctx context.Context) error
+	// CancelCompletionNotification cancels any scheduled "turn ended"
+	// notification for the provided sessionID.
+	CancelCompletionNotification(sessionID string)
+	// HasPendingCompletionNotification reports whether there is a pending
+	// turn-end notification for the given session.
+	HasPendingCompletionNotification(sessionID string) bool
 }
 
 type coordinator struct {
@@ -153,6 +159,22 @@ func (c *coordinator) Run(ctx context.Context, sessionID string, prompt string,
 	})
 }
 
+// CancelCompletionNotification implements Coordinator.
+func (c *coordinator) CancelCompletionNotification(sessionID string) {
+	if c.currentAgent == nil || sessionID == "" {
+		return
+	}
+	c.currentAgent.CancelCompletionNotification(sessionID)
+}
+
+// HasPendingCompletionNotification implements Coordinator.
+func (c *coordinator) HasPendingCompletionNotification(sessionID string) bool {
+	if c.currentAgent == nil || sessionID == "" {
+		return false
+	}
+	return c.currentAgent.HasPendingCompletionNotification(sessionID)
+}
+
 func getProviderOptions(model Model, providerCfg config.ProviderConfig) fantasy.ProviderOptions {
 	options := fantasy.ProviderOptions{}
 

internal/tui/components/chat/chat.go 🔗

@@ -129,6 +129,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return m, nil // Ignore clicks outside the component
 		}
 		if msg.Button == tea.MouseLeft {
+			if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+				m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+			}
 			cmds = append(cmds, m.handleMouseClick(x, y))
 			return m, tea.Batch(cmds...)
 		}
@@ -148,6 +151,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			return m, nil // Ignore clicks outside the component
 		}
 		if msg.Button == tea.MouseLeft {
+			if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+				m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+			}
 			m.listCmp.EndSelection(x, y)
 		}
 		return m, tea.Batch(cmds...)
@@ -185,6 +191,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			if msg.endSelection {
 				m.listCmp.EndSelection(msg.x, msg.y)
 			}
+			if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+				m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+			}
 			m.listCmp.SelectionStop()
 			cmds = append(cmds, m.CopySelectedText(true))
 			return m, tea.Batch(cmds...)
@@ -207,6 +216,9 @@ func (m *messageListCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		return m, tea.Batch(cmds...)
 
 	case tea.MouseWheelMsg:
+		if m.app != nil && m.app.AgentCoordinator != nil && m.session.ID != "" && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		u, cmd := m.listCmp.Update(msg)
 		m.listCmp = u.(list.List[list.Item])
 		cmds = append(cmds, cmd)

internal/tui/components/chat/editor/editor.go 🔗

@@ -215,11 +215,18 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		if m.app.AgentCoordinator.IsSessionBusy(m.session.ID) {
 			return m, util.ReportWarn("Agent is working, please wait...")
 		}
+		if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		return m, m.openEditor(m.textarea.Value())
 	case OpenEditorMsg:
 		m.textarea.SetValue(msg.Text)
 		m.textarea.MoveToEnd()
 	case tea.PasteMsg:
+		// Interaction: cancel any pending turn-end notification for this session.
+		if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		path := strings.ReplaceAll(string(msg), "\\ ", " ")
 		// try to get an image
 		path, err := filepath.Abs(strings.TrimSpace(path))
@@ -261,6 +268,10 @@ func (m *editorCmp) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		m.setEditorPrompt()
 		return m, nil
 	case tea.KeyPressMsg:
+		// Interaction: cancel any pending turn-end notification for this session.
+		if m.app.AgentCoordinator != nil && m.app.AgentCoordinator.HasPendingCompletionNotification(m.session.ID) {
+			m.app.AgentCoordinator.CancelCompletionNotification(m.session.ID)
+		}
 		cur := m.textarea.Cursor()
 		curIdx := m.textarea.Width()*cur.Y + cur.X
 		switch {

internal/tui/page/chat/chat.go 🔗

@@ -174,6 +174,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			msg.Y -= 1
 		}
 		if p.isMouseOverChat(msg.X, msg.Y) {
+			// Interaction: cancel any pending turn-end notification for this session.
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+				p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+			}
 			u, cmd := p.chat.Update(msg)
 			p.chat = u.(chat.MessageListCmp)
 			return p, cmd
@@ -246,6 +250,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		cmds = append(cmds, cmd)
 		return p, tea.Batch(cmds...)
 	case commands.ToggleCompactModeMsg:
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		p.forceCompact = !p.forceCompact
 		var cmd tea.Cmd
 		if p.forceCompact {
@@ -257,8 +264,14 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 		return p, tea.Batch(p.SetSize(p.width, p.height), cmd)
 	case commands.ToggleThinkingMsg:
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		return p, p.toggleThinking()
 	case commands.OpenReasoningDialogMsg:
+		if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		return p, p.openReasoningDialog()
 	case reasoning.ReasoningEffortSelectedMsg:
 		return p, p.handleReasoningEffortSelected(msg.Effort)
@@ -316,6 +329,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		return p, tea.Batch(cmds...)
 	case commands.ToggleYoloModeMsg:
 		// update the editor style
+		if p.app != nil && p.app.AgentCoordinator != nil && p.session.ID != "" && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		u, cmd := p.editor.Update(msg)
 		p.editor = u.(editor.Editor)
 		return p, cmd
@@ -360,6 +376,11 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 		}
 		return p, p.newSession()
 	case tea.KeyPressMsg:
+		// If the chat pane is focused, any key-based navigation in chat counts
+		// as interaction; cancel any pending turn-end notification.
+		if p.focusedPane == PanelTypeChat && p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+			p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+		}
 		switch {
 		case key.Matches(msg, p.keyMap.NewSession):
 			// if we have no agent do nothing
@@ -374,6 +395,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 			agentCfg := config.Get().Agents[config.AgentCoder]
 			model := config.Get().GetModelByType(agentCfg.Model)
 			if model.SupportsImages {
+				if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+					p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+				}
 				return p, util.CmdHandler(commands.OpenFilePickerMsg{})
 			} else {
 				return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
@@ -384,6 +408,9 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				p.splash = u.(splash.Splash)
 				return p, cmd
 			}
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+				p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+			}
 			p.changeFocus()
 			return p, nil
 		case key.Matches(msg, p.keyMap.Cancel):
@@ -391,6 +418,10 @@ func (p *chatPage) Update(msg tea.Msg) (util.Model, tea.Cmd) {
 				return p, p.cancel()
 			}
 		case key.Matches(msg, p.keyMap.Details):
+			// Opening/closing the sidebar counts as interaction; cancel pending notification.
+			if p.app.AgentCoordinator != nil && p.app.AgentCoordinator.HasPendingCompletionNotification(p.session.ID) {
+				p.app.AgentCoordinator.CancelCompletionNotification(p.session.ID)
+			}
 			p.toggleDetails()
 			return p, nil
 		}

internal/tui/tui.go 🔗

@@ -191,6 +191,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case commands.SwitchModelMsg:
+		// Opening model dialog is interaction; cancel pending turn-end notif.
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
+			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
+		}
 		return a, util.CmdHandler(
 			dialogs.OpenDialogMsg{
 				Model: models.NewModelDialogCmp(),
@@ -424,8 +428,13 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		a.status.ToggleFullHelp()
 		a.showingFullHelp = !a.showingFullHelp
 		return a.handleWindowResize(a.wWidth, a.wHeight)
-	// dialogs
+		// dialogs
 	case key.Matches(msg, a.keyMap.Commands):
+		// Opening the command palette counts as interaction; cancel pending
+		// turn-end notification for the selected session if any.
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
+			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
+		}
 		// if the app is not configured show no commands
 		if !a.isConfigured {
 			return nil
@@ -444,6 +453,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		if !a.isConfigured {
 			return nil
 		}
+		// Opening sessions dialog is interaction; cancel pending turn-end notif.
+		if a.app.AgentCoordinator != nil && a.app.AgentCoordinator.HasPendingCompletionNotification(a.selectedSessionID) {
+			a.app.AgentCoordinator.CancelCompletionNotification(a.selectedSessionID)
+		}
 		if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
 			return util.CmdHandler(dialogs.CloseDialogMsg{})
 		}