Skip to content

[RemoteChat] - Complete remote UI, modals, and notifications #1492

Description

@JoshuaRowePhantom

Part of #1483

Summary

Complete remote session opening UI, modal stacks, running-row interrupt/terminate/Continue in background controls, immutable queue UX, and notification kinds.

Scope

Implement only commit 9 of the approved design. Preserve the contracts below exactly; local and remote behavior must remain compatible. Imported surfaces consumed here must preserve the exact property-based Usage and AgentInformation names, fields, types, and semantics from detail 2; protocol DTO/property records must preserve the approved serializers and fixed discriminators; any imported interrupt contract remains InterruptAsync(Guid commandId, CancellationToken ct = default) with no InterruptAgentSessionRequest; APIs owned by #1471-#1477 stay unchanged unless this design explicitly changes their presentation; and the audited design total remains exactly 28 request/options types (22 public and 6 internal).

Files

Files: AgentViewModel, AgentSessionWorkspaceTabViewModel, editor/modal controls,
Phantom.Workspaces/ViewModels/RunningAgentBrainViewModel.cs,
Phantom.Workspaces/ViewModels/RunningAgentRowViewModel.cs,
Phantom.Workspaces/Controls/RunningAgentBrainControl.axaml(.cs), Notification,
NotificationEntry, NotificationTargetRequest, INotificationService, NotificationService, and
NotificationsViewModel.
Tests: UI request/options required-member/default/named-initializer tests, UI public API tests
above, multiple modal ownership, descendant aggregation, independent
notification clearing, remote interrupt versus terminate, and checkbox binding, command,
enabled/pending state, and accessibility.

Detailed design

UI integration types - Existing, Modified

  • RunningAgentBrainViewModel, RunningAgentRowViewModel, and RunningAgentBrainControl:
    Phantom.Workspaces.ViewModels / Phantom.Workspaces.Controls;
    Phantom.Workspaces/ViewModels/RunningAgentBrainViewModel.cs,
    Phantom.Workspaces/ViewModels/RunningAgentRowViewModel.cs, and
    Phantom.Workspaces/Controls/RunningAgentBrainControl.axaml(.cs). These are the existing
    brain-icon popup, Rows collection, and row type; this design adds no parallel “sessions flyout.”
    RunningAgentBrainViewModel.Refresh() continues to derive top-level rows from
    IRunningAgentChatTable.RunningSessions and continues filtering IsSubAgent.
    RunningAgentRowViewModel adds the following public row state:

    public bool IsRemote { get; }
    public bool ContinueInBackground { get; }
    public int ViewerCount { get; }
    public bool IsBackgroundOptionEnabled { get; }
    public bool IsInterruptEnabled { get; }
    public ICommand InterruptCommand { get; }
    public ICommand TerminateCommand { get; }
    public ICommand SetContinueInBackgroundCommand { get; }

    Its two existing public constructors remain source-compatible. An internal constructor/factory
    used by RunningAgentBrainViewModel supplies runtime metadata and commands; legacy callers receive
    disabled no-op runtime commands and false/zero metadata.

    InterruptCommand acquires the existing session lease and invokes IAgentChat.Interrupt; it is
    enabled only while that row has an active interruptible turn.
    TerminateCommand calls IRunningAgentChatTable.TerminateAsync and removes the row only through
    the resulting RunningSessions change. Both are disabled while terminal/disconnected, and
    terminate disables every row command while pending. The background command is created by
    RunningAgentBrainViewModel and calls
    IRunningAgentChatTable.SetContinueInBackgroundAsync(SessionId, requestedValue). It is disabled
    while an update is pending, after terminal/disconnect, or when the row lacks a persisted top-level
    session/owning transport; it is enabled for an attached remote row and for a locally owned runtime
    capable of accepting remote attachments. ContinueInBackground changes only from the
    authoritative running-session metadata after persistence/event application. Failure leaves the
    old checked state and reports the existing safe operation error.

    RunningAgentBrainControl places one checkbox in each running row beside the existing activate
    button. Its visible label and AutomationProperties.Name are exactly “Continue in background”;
    its tooltip is “Keep this agent running when the last viewer disconnects.” IsChecked is
    one-way bound to ContinueInBackground, IsEnabled to IsBackgroundOptionEnabled, and its
    command/boolean parameter to SetContinueInBackgroundCommand. Keyboard focus and Space invoke the
    checkbox without activating the row; the row's activate button remains separately focusable. A
    red, right-aligned X button binds InterruptCommand, IsEnabled to IsInterruptEnabled,
    tooltip and AutomationProperties.Name to “Interrupt agent”, and is distinct from the
    terminate action. A separate destructive Terminate button follows it, binds
    TerminateCommand, is disabled while termination is pending or the row is disconnected/terminal,
    and uses tooltip and AutomationProperties.Name “Terminate agent session”. It sends the
    explicit terminate verb; it never merely closes the tab or releases a viewer lease.

  • AgentViewModel:
    Phantom.Workspaces.Agent.Gui.ViewModels;
    Phantom.Workspaces.Agent.Gui/ViewModels/AgentViewModel.cs; existing public sealed class.
    Its constructor becomes AgentViewModel(AgentViewModelOptions options). AgentViewModelOptions
    has required-init IAgentChat AgentChat, string DisplayName, string Description,
    ObservableLoggerFactory LoggerFactory, and TaskScheduler ForegroundScheduler, plus
    AgentViewModel? ParentAgentViewModel { get; init; } = null. Existing foreground validation
    remains. It uses the common collections/events, awaits async remote tool toggles, and owns a
    ReadOnlyObservableCollection<AgentSessionModalViewModel> Modals. Modal response calls
    Task RespondToModalAsync(string modalId, JsonElement response, CancellationToken ct = default),
    which delegates to IAgentChat, preserves the modal until a dismiss event, and maps cancellation
    or safe remote errors without optimistic removal.
    Input is gated only while this editor has an unresolved modal; descendant modals affect only root
    notification aggregation. Its existing public AgentChat getter changes to
    public IAgentChat AgentChat { get; }. Disposal unsubscribes before disposing its chat lease/proxy.

  • InputQueueViewModel:
    Phantom.Workspaces.Agent.Gui.ViewModels; existing public sealed class. Its constructor and
    fields consume IAgentChat.InputQueues, IAgentInputQueue, and immutable snapshots rather than
    concrete AgentChat, AgentChatQueue, or AgentInputQueueManager. Submit, create, edit, remove,
    move, hold/release, and target selection await the aggregate commands. Controls stay unchanged
    until the acknowledged owner delta/result is applied; conflict replaces the projection from the
    returned snapshot and prompts/retries only after reconciling user intent. Changed is marshalled
    to the existing foreground scheduler, and disposal unsubscribes without disposing the chat-owned
    aggregate.

  • AgentSessionWorkspaceTabViewModel:
    Phantom.Workspaces.ViewModels;
    Phantom.Workspaces/ViewModels/AgentSessionWorkspaceTabViewModel.cs; existing public sealed class.
    Adds read-only bool IsRemote, string? RemoteProfileDisplayName, and
    bool HasModalsNeedingInput; SetReady derives these from the chat/view model. The
    modal-pending notification clears only when aggregate modal count reaches zero; chat-idle
    retains activation-clearing behavior.

  • OpenAgentSessionShortcutHandler:
    same namespace; existing public sealed class. Handle,
    TryCreateAgentSessionTabForRestoreAsync, TryCreateTabForRestoreAsync, and
    CreateAgentSessionTabAsync all call one new internal
    OpenPersistedSessionAsync(JsonElement, AgentSessionOpenIntent, CancellationToken).
    TryCreateAgentSessionTabForRestoreAsync takes
    CreateAgentSessionTabForRestoreRequest plus CancellationToken; CreateAgentSessionTabAsync
    takes CreateAgentSessionTabRequest plus CancellationToken; and
    ComposeSessionAgentViewModel takes ComposeSessionAgentViewModelOptions. The inherited
    Handle and TryCreateTabForRestoreAsync overrides retain their framework signatures.
    ConnectOnOwner builds a remote acquisition request; ResumeLocally performs takeover before a
    local acquisition. It never parses executor bindings or compiles trust policy.

  • Notification, NotificationEntry, INotificationService, NotificationService, and
    NotificationsViewModel: existing notification types in
    Phantom.Workspaces/Services/Notifications and Phantom.Workspaces/ViewModels. Notification
    adds nonblank string Kind with the "legacy" default; NotificationEntry exposes it.
    INotificationService and NotificationService add
    void Remove(NotificationTargetRequest request) and
    void MarkRead(NotificationTargetRequest request) while retaining the existing tab-wide overloads for
    callers that intentionally affect every kind. Notify upserts only the matching (TabKey, Kind).
    NotificationsViewModel.UnreadCount and HasUnread aggregate all entries and therefore require no
    signature change.

    public record Notification
    {
        public required TabDescriptor TabDescriptor { get; init; }
        public required string Heading { get; init; }
        public required string Description { get; init; }
        public required DateTime When { get; init; }
        public required RunningState RunningState { get; init; }
        public required NotificationState NotificationState { get; init; }
        public string Kind { get; init; } = "legacy";
    }
    
    public sealed record NotificationEntry
    {
        public required string TabKey { get; init; }
        public required string Kind { get; init; }
        // Existing required TabDescriptor, Heading, Description, When,
        // IsRunning, IsInteresting, IsRead, and IsSnoozed properties remain.
    }
    
    public sealed record NotificationTargetRequest
    {
        public required string TabId { get; init; }
        public required string Kind { get; init; }
    }
    
    public interface INotificationService
    {
        void Notify(Notification notification);
        void Remove(string tabId);
        void Remove(NotificationTargetRequest request);
        void MarkRead(string tabId);
        void MarkRead(NotificationTargetRequest request);
        // Existing members remain.
    }

    NotificationService.Notify rejects null and blank Kind; "legacy" preserves existing caller
    source compatibility while still giving every stored entry a nonblank key.

The added/changed public signatures are:

public sealed record AgentViewModelOptions
{
    public required IAgentChat AgentChat { get; init; }
    public required string DisplayName { get; init; }
    public required string Description { get; init; }
    public required ObservableLoggerFactory LoggerFactory { get; init; }
    public required TaskScheduler ForegroundScheduler { get; init; }
    public AgentViewModel? ParentAgentViewModel { get; init; } = null;
}

public AgentViewModel(AgentViewModelOptions options);
public IAgentChat AgentChat { get; }
public ReadOnlyObservableCollection<AgentSessionModalViewModel> Modals { get; }
public Task RespondToModalAsync(
    string modalId, JsonElement response, CancellationToken ct = default);

public bool IsRemote { get; }
public string? RemoteProfileDisplayName { get; }
public bool HasModalsNeedingInput { get; }
public void SetReady(AgentViewModel agentViewModel, ObservableLoggerFactory factory);

public override Task<bool> Handle(
    MainWindowViewModel mainWindowViewModel,
    Shortcut shortcut,
    SubscribedEntityViewModel entityViewModel);
public Task<AgentSessionWorkspaceTabViewModel?> TryCreateAgentSessionTabForRestoreAsync(
    CreateAgentSessionTabForRestoreRequest request, CancellationToken ct = default);
public override Task<WorkspaceTabViewModel?> TryCreateTabForRestoreAsync(
    MainWindowViewModel mainWindowViewModel,
    SubscribedEntityViewModel entityViewModel,
    string? tabId,
    string? title,
    string? dockRegion);
public Task<AgentSessionWorkspaceTabViewModel> CreateAgentSessionTabAsync(
    CreateAgentSessionTabRequest request, CancellationToken ct = default);
public AgentViewModel ComposeSessionAgentViewModel(ComposeSessionAgentViewModelOptions options);

public sealed record CreateAgentSessionTabForRestoreRequest
{
    public required MainWindowViewModel MainWindowViewModel { get; init; }
    public required SubscribedEntityViewModel AgentSessionEntity { get; init; }
    public string? TabId { get; init; } = null;
    public string? Title { get; init; } = null;
    public string? DockRegion { get; init; } = null;
}

public sealed record CreateAgentSessionTabRequest
{
    public required MainWindowViewModel MainWindowViewModel { get; init; }
    public required SubscribedEntityViewModel AgentSessionEntity { get; init; }
    public required IAgentChat AgentChat { get; init; }
}

public sealed record ComposeSessionAgentViewModelOptions
{
    public required MainWindowViewModel MainWindowViewModel { get; init; }
    public required ObservableLoggerFactory LoggerFactory { get; init; }
    public required IAgentChat AgentChat { get; init; }
    public required SubscribedEntityViewModel AgentSessionEntity { get; init; }
    public required AgentSessionWorkspaceTabViewModel Tab { get; init; }
    public required TaskScheduler ForegroundScheduler { get; init; }
}

var viewModel = new AgentViewModel(
    new AgentViewModelOptions
    {
        AgentChat = agentChat,
        DisplayName = displayName,
        Description = description,
        LoggerFactory = loggerFactory,
        ForegroundScheduler = foregroundScheduler,
    });

All four UI getters are foreground-owned and nonblocking. SetReady remains one-shot, rejects null
arguments/failed or disposed tabs, replaces loading state atomically, then raises property and
notification changes. No UI operation crosses transport except through the IAgentChat command
methods.

AgentSessionModalViewModel is a New public sealed view model in
Phantom.Workspaces.Agent.Gui.ViewModels,
Phantom.Workspaces.Agent.Gui/ViewModels/AgentSessionModalViewModel.cs. It exposes immutable
string Id, string Title, string Body, and AgentChatModalContent Content, plus
Task RespondAsync(JsonElement response, CancellationToken ct = default). It delegates once to its
owning AgentViewModel; invalid freeform, choice, or approval responses fail before transport,
cancellation leaves the modal pending, and only a later ordered dismiss event removes it.
AgentSessionModalStackControl is a New public sealed Avalonia control in
Phantom.Workspaces.Agent.Gui.Controls,
Phantom.Workspaces.Agent.Gui/Controls/AgentSessionModalStackControl.axaml(.cs). It adds no public
method and binds only the current editor's modal collection.

SlashCommandContext is Existing, Modified in Phantom.Workspaces.Llm.SlashCommands,
Phantom.Workspaces.Llm.Core/SlashCommands/SlashCommandContext.cs: its
public required IAgentChat AgentChat { get; init; } replaces the concrete type. The init setter
rejects null. Handlers needing engine-only APIs are not registered by RemoteAgentChat.SlashCommands;
common handlers use only IAgentChat. This prevents a hidden concrete cast in the open path.

UI, sessions view, and notifications

  • OpenAgentSessionShortcutHandler is the sole GUI choice point for local, connect-on-owner, and
    takeover paths, including restore.
  • RunningAgentBrainViewModel.Rows contains RunningAgentRowViewModel values with placement, owner
    display, interrupt, a separate terminate action, viewer count, and authoritative background
    preference. A lease close never sends the terminate verb, although final release may activate the
    default lifecycle policy.
  • AgentViewModel owns only its modal stack and input gate. The root's
    HasModalsNeedingInput is the OR of self and descendants.
  • AgentChatEditorControl renders AgentSessionModalStackControl above the input queue.
  • InputQueueViewModel binds only immutable common snapshots. It awaits commands and owner deltas;
    it never receives owner collections or performs an optimistic mutation.
  • Notifications are per-tab and keyed by kind. Activation clears chat-idle; only an empty aggregate
    modal set clears modal-pending. Notification, NotificationEntry, and NotificationService
    add a nonblank Kind (Notification.Kind defaults to "legacy", while stored entries require an
    explicit value); replacement/removal/read state uses (TabKey, Kind) rather than
    only TabKey. Existing callers use a stable legacy kind. NotificationsViewModel.UnreadCount
    counts all unread entries and HasUnread is UnreadCount > 0, so it is the app-wide logical OR
    across chat-idle, modal-pending, and future kinds.
  • New Phantom.Workspaces.Data.Core/JsonEntities/entity-type-views/agent-session-entity-type-view.json
    groups by the existing persisted host-profile-entity-id through group-by-parent; there is no
    parallel Sessions UI.

Tests

UI public API tests

AgentViewModelTests:

  • Constructor_RemoteChat_UsesCommonSurfaceWithoutConcreteCast.
  • AgentViewModelOptions_NamedInitializer_PreservesRequiredValuesAndParentDefault.
  • Constructor_WrongForegroundContext_Throws.
  • RespondToModalAsync_CurrentModal_SendsResponseAndKeepsInputGatedUntilDismissed.
  • RespondToModalAsync_UnknownModal_ThrowsArgumentException.
  • RespondToModalAsync_Cancelled_DoesNotDismissModal.
  • ModalEvent_DescendantModal_UpdatesRootAggregateOnly.
  • DisposeAsync_RemoteChat_UnsubscribesBeforeDetaching.
  • InterruptCommand_RemoteChat_InvokesCommonInterrupt.
  • ConfigureSlashCommands_RemoteChat_RegistersOnlyCommonHandlers.

InputQueueViewModelTests:

  • CommandPending_RemoteQueue_DoesNotMutateProjectionOptimistically.
  • CommandConflict_StaleRevision_RefreshesFromAuthoritativeSnapshot.
  • CommandApplied_AuthoritativeDeltaAppliedBeforeTaskCompletes.

SlashCommandContextTests:

  • AgentChatSetter_LocalOrRemote_PreservesCommonChat.
  • AgentChatSetter_Null_RejectsInitialization.

AgentSessionModalViewModelTests:

  • RespondAsync_ValidOption_DelegatesOnceAndWaitsForDismissEvent.
  • RespondAsync_InvalidOption_RejectsBeforeTransport.
  • RespondAsync_Cancelled_KeepsModalPending.

AgentSessionWorkspaceTabViewModelTests:

  • SetReady_RemoteAgent_SetsRemoteMetadata.
  • SetReady_LocalAgent_ClearsRemoteMetadata.
  • SetReady_AgentWithModal_SetsModalPendingNotification.
  • ModalDismissed_LastRelevantModal_ClearsModalPendingNotification.
  • TabActivated_IdleAndModalNotifications_ClearsOnlyIdle.

OpenAgentSessionShortcutHandlerTests:

  • Handle_OwnerIsCurrentProfile_AcquiresLocalRuntime.
  • Handle_OwnerIsRemoteConnectChoice_AttachesOnOwner.
  • Handle_OwnerIsRemoteResumeChoice_CompletesTakeoverBeforeLocalAcquire.
  • TryCreateAgentSessionTabForRestoreAsync_RemoteOwner_UsesSameChoicePipeline.
  • TryCreateTabForRestoreAsync_RemoteOwner_UsesSameChoicePipeline.
  • Handle_RemoteOwnerPrompt_ShowsAuthorizedRunningStatus.
  • Handle_RemoteOwnerPrompt_ShowsAuthorizedNotRunningStatus.
  • Handle_RemoteOwnerPrompt_UnauthorizedOrMissingShowsUnavailable.
  • CreateAgentSessionTabAsync_PersistedSession_PassesEntityToAcquisition.
  • ComposeSessionAgentViewModel_RemoteChat_ConfiguresCommonSlashCommandSurface.
  • SessionUiRequestOptions_NamedInitializers_PreserveRequiredValuesAndOptionalDefaults.
  • DisposeAsync_InitializationInFlight_CancelsWithoutPublishingReadyTab.

RunningAgentBrainViewModelTests:

  • Refresh_RemoteTopLevelSession_CreatesRunningAgentRowWithRetentionState.
  • Refresh_Subagent_RemainsExcluded.
  • InterruptCommand_EnabledRow_InvokesCommonInterrupt.
  • InterruptCommand_NoInterruptibleRun_IsDisabled.
  • SetContinueInBackgroundCommand_EnabledRemoteRow_CallsRunningTableOnce.
  • SetContinueInBackgroundCommand_UpdatePending_DisablesUntilAuthoritativeRefresh.
  • SetContinueInBackgroundCommand_Rejected_KeepsAuthoritativeCheckedState.
  • TerminateCommand_RemoteRow_TerminatesAndRemovesRow.

RunningAgentBrainControlTests:

  • ContinueInBackgroundCheckbox_RemoteRow_BindsCheckedEnabledAndCommand.
  • ContinueInBackgroundCheckbox_Accessibility_UsesRequiredLabelAndTooltip.
  • ContinueInBackgroundCheckbox_KeyboardSpace_DoesNotActivateRow.
  • InterruptButton_ActiveRun_IsRedRightAlignedAndAccessible.
  • TerminateButton_ConnectedRow_IsDistinctAccessibleAndSendsExplicitTerminate.

NotificationServiceTests and NotificationsViewModelTests:

  • Notification_PropertyShape_RequiredMembersAndLegacyKindDefault_ArePreserved.
  • Notification_NamedInitializer_PreservesAllValues.
  • Notify_SameTabDifferentKinds_PreservesIndependentEntries.
  • Notify_BlankKind_ThrowsArgumentException.
  • NotificationTargetRequest_NamedInitializer_TargetsOneKind.
  • Remove_TabAndKind_RemovesOnlyMatchingKind.
  • MarkRead_TabAndKind_MarksOnlyMatchingKind.
  • HasUnread_AnyUnreadKind_ReturnsTrueUntilAllKindsRead.
  • TabActivated_ChatIdleAndModalPending_ClearsOnlyChatIdle.
  • ModalDismissed_LastRelevantModal_ClearsOnlyModalPending.

Dependencies

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingverified-locallyImplementation has been verified locally

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions