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.cs — InputBox_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. |
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 ontoNotificationIndicatorTabHeaderItemViewModel.HasUnread(rendered with theexclamation-indicatorstyle).Definition —
Phantom.Workspaces/ViewModels/TabHeaderViewModel.cs:55-63:Clear method —
Phantom.Workspaces/Services/Notifications/INotificationService.cs:10:MarkReadis currently invoked in only a few places, all inMainWindowViewModel.cs:GoToWorkspacePaneAtIndexCommandflow).OnActiveDockableChangedwhen aWorkspaceDocumentbecomes the active dockable (the tab-selection path).OnNavigateNotification(Alt+notification-nav shortcut).There is no invocation of
MarkReadon the agent input path. The user's typed text flows through:Phantom.Workspaces.Agent.Gui/Controls/QueueComposerControl.axaml.cs—InputBox_KeyDownsetsvm.InputText = tb.Text ?? string.Empty;Phantom.Workspaces.Agent.Gui/ViewModels/QueueComposerViewModel.cs:119-129— theInputTextsetter firesOnInputTextChanged(value).QueueComposerViewModel.cs:400-...—OnInputTextChangedonly manages the slash-command completions popup; it does not signal any notification-acknowledgement:The composer /
InputQueueViewModel/AgentViewModelalso have no reference toINotificationServiceand no knowledge of the owningWorkspaceDocument.Id(the tab id needed byMarkRead). 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
Phantom.Workspaces.Agent.Gui/ViewModels/QueueComposerViewModel.csInputTextsetter; needs to raise a "user-input-activity" signal on the empty→non-empty transition.Phantom.Workspaces.Agent.Gui/ViewModels/InputQueueViewModel.csDefaultComposerand constructs additional per-queueQueueComposerViewModels; forwards the signal upward.Phantom.Workspaces.Agent.Gui/ViewModels/AgentViewModel.csInputQueueViewModelat line 79; exposes the signal to its host.Phantom.Workspaces/ViewModels/WorkspaceDocument.csAgentViewModeland owns theId(tab id) required byMarkRead.Phantom.Workspaces/ViewModels/MainWindowViewModel.csINotificationService; wires the composer signal tonotificationService.MarkRead(doc.Id)on the UI-thread scheduler (same call used inOnActiveDockableChangedat line 4165).Phantom.Workspaces/Services/Notifications/INotificationService.csMarkRead(string tabId)— the shared clear API.Design / Fix
Hook the compose-input path to invoke the existing
INotificationService.MarkRead(tabId)on the owningWorkspaceDocument.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 (SubmitsetsInputText = string.Empty) and don't spamMarkReadon every keystroke.Step 1 — Raise a "user-input-activity" event from
QueueComposerViewModelDetect the empty → non-empty transition in the
InputTextsetter and raise an event (guardsuppressActivitySignalfor programmatic writes such asSubmit, history navigation, and attachment placeholder insertion so those don't count as user typing).Wrap programmatic setters (
Submitline 332/346,NavigateHistoryline 255-256, attachment placeholder insertion line 185/188/229/490) withsuppressActivitySignal = true/falseso only genuine user keystrokes fire the event.Step 2 — Forward the event through
InputQueueViewModel/AgentViewModelInputQueueViewModelsubscribes toDefaultComposer.UserInputActivity(and any per-queue composers it creates) and re-raises its ownUserInputActivity.AgentViewModeldoes the same, so hosts don't need to know about the composer topology.Step 3 — Wire
MarkReadat theWorkspaceDocumenthost site (foreground context)Where
WorkspaceDocumentis constructed with itsAgentViewModelandMainWindowViewModelhasINotificationServicein scope, subscribe:This reuses the exact call that
OnActiveDockableChangedmakes atMainWindowViewModel.cs:4165, on the UI/foreground context. Idempotent: if the notification is already read,MarkReadis a no-op.Guard properties:
InputText(Submit clearing, history recall, attachment placeholder insertion) do not fire the event.Considered / Background
MarkReaddirectly fromQueueComposerViewModel. Rejected — the composer doesn't know its tab id and shouldn't take a dependency onINotificationService; the current pattern keepsMarkReadcalls inMainWindowViewModel.Expected Tests
Add view-model level tests alongside existing composer tests (
Phantom.Workspaces.Agent.Gui.Tests\QueueComposer*Tests.cs) and an integration test inPhantom.Workspaces.Tests\AgentSessionNotificationTests.csfollowing the naming style already used there and inTabHeaderViewModelTests.QueueComposerViewModel_InputTextTransitionsFromEmptyToNonEmpty_RaisesUserInputActivityQueueComposerUserInputActivityTests(new, inPhantom.Workspaces.Agent.Gui.Tests)InputTextfrom""to"h"raisesUserInputActivityexactly once.QueueComposerViewModel_InputTextChangesFromNonEmptyToNonEmpty_DoesNotRaiseUserInputActivityQueueComposerUserInputActivityTestsQueueComposerViewModel_SubmitClearsInputText_DoesNotRaiseUserInputActivityQueueComposerUserInputActivityTestsSubmitis suppressed.QueueComposerViewModel_HistoryNavigationSetsInputText_DoesNotRaiseUserInputActivityQueueComposerUserInputActivityTestsQueueComposerViewModel_AttachmentPlaceholderInsert_DoesNotRaiseUserInputActivityQueueComposerUserInputActivityTestsInputQueueViewModel_DefaultComposerRaisesUserInputActivity_ForwardsEventInputQueueViewModelTestsUserInputActivityfrom its default composer.AgentSession_TypingIntoInput_ClearsAgentNotificationIconAgentSessionNotificationTestsHasUnreadNotification = true, typing into its input causesINotificationService.MarkRead(tabId)to be invoked and the indicator to clear.AgentSession_TypingIntoInput_DoesNotClearOtherAgentNotificationsAgentSessionNotificationTestsMarkReadfor agent B's tab id; B's notification icon remains set.AgentSession_ProgrammaticInputTextClearAfterSubmit_DoesNotClearNotificationAgentSessionNotificationTestsInputText = string.Emptyperformed bySubmitdoes not itself trigger aMarkReadcall.