Skip to content

Typing into an agent's chat input should clear that agent's notification (exclamation) icon #1167

Description

@JoshuaRowePhantom

Summary

An agent chat tab shows a notification (exclamation-mark) indicator when the agent has produced output the user has not yet acknowledged. Activating the tab clears the indicator via INotificationService.MarkRead(tabId), but typing into the agent's chat input does not. If the user is actively engaging with the agent by composing a message, the notification icon should be considered acknowledged and cleared — instead it persists indefinitely, giving stale/misleading attention state on the tab strip. This is a defect in the notification-clear surface: the "user attended to this agent" signal is not wired to the input-compose path.

Related: this bug shares the notification-clear mechanism with the sibling bug being filed for "navigating to a tab via switching workspaces should clear the notification icon", and with #1157 (notification navigation). All three route through INotificationService.MarkRead.

Root Cause

The notification indicator on an agent chat tab is driven by WorkspaceDocument.HasUnreadNotification, which is projected onto NotificationIndicatorTabHeaderItemViewModel.HasUnread (rendered with the exclamation-indicator style).

Definition — Phantom.Workspaces/ViewModels/TabHeaderViewModel.cs:55-63:

public sealed class NotificationIndicatorTabHeaderItemViewModel : TabHeaderItemViewModel
{
    private bool hasUnread;
    public bool HasUnread
    {
        get => this.hasUnread;
        set => this.SetProperty(ref this.hasUnread, value);
    }
}

Clear method — Phantom.Workspaces/Services/Notifications/INotificationService.cs:10:

void MarkRead(string tabId);

MarkRead is currently invoked in only a few places, all in MainWindowViewModel.cs:

  • Line 1986 — programmatic tab activation (GoToWorkspacePaneAtIndexCommand flow).
  • Line 2316 — tab activation via another command path.
  • Line 4165 — OnActiveDockableChanged when a WorkspaceDocument becomes the active dockable (the tab-selection path).
  • Line 4210 — OnNavigateNotification (Alt+notification-nav shortcut).

There is no invocation of MarkRead on the agent input path. The user's typed text flows through:

  • Phantom.Workspaces.Agent.Gui/Controls/QueueComposerControl.axaml.csInputBox_KeyDown sets vm.InputText = tb.Text ?? string.Empty;
  • Phantom.Workspaces.Agent.Gui/ViewModels/QueueComposerViewModel.cs:119-129 — the InputText setter fires OnInputTextChanged(value).
  • QueueComposerViewModel.cs:400-...OnInputTextChanged only manages the slash-command completions popup; it does not signal any notification-acknowledgement:
public string InputText
{
    get => this.inputText;
    set
    {
        if (this.SetProperty(ref this.inputText, value))
        {
            this.OnInputTextChanged(value);
        }
    }
}

private void OnInputTextChanged(string text)
{
    this.completionsCts?.Cancel();
    this.completionsCts?.Dispose();
    // ... slash-command completions only ...
}

The composer / InputQueueViewModel / AgentViewModel also have no reference to INotificationService and no knowledge of the owning WorkspaceDocument.Id (the tab id needed by MarkRead). So today typing produces zero notification-clear signal from the agent input path.

Result: an agent tab's exclamation indicator remains set even while the user is actively typing a message into that agent's compose box.

Affected Files

File Role
Phantom.Workspaces.Agent.Gui/ViewModels/QueueComposerViewModel.cs Owns the InputText setter; needs to raise a "user-input-activity" signal on the empty→non-empty transition.
Phantom.Workspaces.Agent.Gui/ViewModels/InputQueueViewModel.cs Owns DefaultComposer and constructs additional per-queue QueueComposerViewModels; forwards the signal upward.
Phantom.Workspaces.Agent.Gui/ViewModels/AgentViewModel.cs Constructs InputQueueViewModel at line 79; exposes the signal to its host.
Phantom.Workspaces/ViewModels/WorkspaceDocument.cs Hosts the AgentViewModel and owns the Id (tab id) required by MarkRead.
Phantom.Workspaces/ViewModels/MainWindowViewModel.cs Owns INotificationService; wires the composer signal to notificationService.MarkRead(doc.Id) on the UI-thread scheduler (same call used in OnActiveDockableChanged at line 4165).
Phantom.Workspaces/Services/Notifications/INotificationService.cs Provides MarkRead(string tabId) — the shared clear API.

Design / Fix

Hook the compose-input path to invoke the existing INotificationService.MarkRead(tabId) on the owning WorkspaceDocument.Id. Fire only once per "engagement", and only when the user actually starts typing (empty → non-empty transition), so we don't fire on programmatic clears (Submit sets InputText = string.Empty) and don't spam MarkRead on every keystroke.

Step 1 — Raise a "user-input-activity" event from QueueComposerViewModel

Detect the empty → non-empty transition in the InputText setter and raise an event (guard suppressActivitySignal for programmatic writes such as Submit, history navigation, and attachment placeholder insertion so those don't count as user typing).

// QueueComposerViewModel.cs
public event EventHandler? UserInputActivity;

public string InputText
{
    get => this.inputText;
    set
    {
        var previous = this.inputText;
        if (this.SetProperty(ref this.inputText, value))
        {
            this.OnInputTextChanged(value);
            if (!this.suppressActivitySignal
                && string.IsNullOrEmpty(previous)
                && !string.IsNullOrEmpty(value))
            {
                this.UserInputActivity?.Invoke(this, EventArgs.Empty);
            }
        }
    }
}

Wrap programmatic setters (Submit line 332/346, NavigateHistory line 255-256, attachment placeholder insertion line 185/188/229/490) with suppressActivitySignal = true/false so only genuine user keystrokes fire the event.

Step 2 — Forward the event through InputQueueViewModel / AgentViewModel

InputQueueViewModel subscribes to DefaultComposer.UserInputActivity (and any per-queue composers it creates) and re-raises its own UserInputActivity. AgentViewModel does the same, so hosts don't need to know about the composer topology.

Step 3 — Wire MarkRead at the WorkspaceDocument host site (foreground context)

Where WorkspaceDocument is constructed with its AgentViewModel and MainWindowViewModel has INotificationService in scope, subscribe:

agentViewModel.InputQueue.UserInputActivity += (_, _) =>
    Dispatcher.UIThread.Post(() => this.notificationService.MarkRead(doc.Id));

This reuses the exact call that OnActiveDockableChanged makes at MainWindowViewModel.cs:4165, on the UI/foreground context. Idempotent: if the notification is already read, MarkRead is a no-op.

Guard properties:

  • Fires only on the empty → non-empty transition (once per compose cycle; typing more characters doesn't re-fire).
  • Fires only for that composer's owning tab id, so typing into agent A does not clear agent B's notification.
  • Programmatic mutations of InputText (Submit clearing, history recall, attachment placeholder insertion) do not fire the event.

Considered / Background

  • Alternative: fire on every keystroke. Rejected — noisier and requires idempotency reasoning at the service; the empty→non-empty edge is a clean "user just began engaging" signal.
  • Alternative: invoke MarkRead directly from QueueComposerViewModel. Rejected — the composer doesn't know its tab id and shouldn't take a dependency on INotificationService; the current pattern keeps MarkRead calls in MainWindowViewModel.

Expected Tests

Add view-model level tests alongside existing composer tests (Phantom.Workspaces.Agent.Gui.Tests\QueueComposer*Tests.cs) and an integration test in Phantom.Workspaces.Tests\AgentSessionNotificationTests.cs following the naming style already used there and in TabHeaderViewModelTests.

Test Name Class What It Verifies
QueueComposerViewModel_InputTextTransitionsFromEmptyToNonEmpty_RaisesUserInputActivity QueueComposerUserInputActivityTests (new, in Phantom.Workspaces.Agent.Gui.Tests) Setting InputText from "" to "h" raises UserInputActivity exactly once.
QueueComposerViewModel_InputTextChangesFromNonEmptyToNonEmpty_DoesNotRaiseUserInputActivity QueueComposerUserInputActivityTests Typing further characters ("h" → "hi") does not re-fire the event.
QueueComposerViewModel_SubmitClearsInputText_DoesNotRaiseUserInputActivity QueueComposerUserInputActivityTests Programmatic clear inside Submit is suppressed.
QueueComposerViewModel_HistoryNavigationSetsInputText_DoesNotRaiseUserInputActivity QueueComposerUserInputActivityTests Recalling history entries does not fire the event.
QueueComposerViewModel_AttachmentPlaceholderInsert_DoesNotRaiseUserInputActivity QueueComposerUserInputActivityTests Adding an attachment placeholder does not fire the event.
InputQueueViewModel_DefaultComposerRaisesUserInputActivity_ForwardsEvent InputQueueViewModelTests The queue view model re-raises UserInputActivity from its default composer.
AgentSession_TypingIntoInput_ClearsAgentNotificationIcon AgentSessionNotificationTests Given an agent tab with HasUnreadNotification = true, typing into its input causes INotificationService.MarkRead(tabId) to be invoked and the indicator to clear.
AgentSession_TypingIntoInput_DoesNotClearOtherAgentNotifications AgentSessionNotificationTests Typing into agent A's input does not call MarkRead for agent B's tab id; B's notification icon remains set.
AgentSession_ProgrammaticInputTextClearAfterSubmit_DoesNotClearNotification AgentSessionNotificationTests The InputText = string.Empty performed by Submit does not itself trigger a MarkRead call.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdiagnosedRoot cause identified

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions