From 93bfd408bc372f4613249c29549758e3964f99bb Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 26 May 2026 13:38:21 +0200 Subject: [PATCH] feat(ipc): wire TypingIpc state from InputBar API --- HellionChat/Ipc/TypingIpc.cs | 47 +++++++++++++++++++++++++------- HellionChat/PluginHostFactory.cs | 1 + 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/HellionChat/Ipc/TypingIpc.cs b/HellionChat/Ipc/TypingIpc.cs index 24f0c80..0275143 100644 --- a/HellionChat/Ipc/TypingIpc.cs +++ b/HellionChat/Ipc/TypingIpc.cs @@ -34,11 +34,13 @@ internal sealed class TypingIpc : IDisposable private ChatInputState LastState; private bool HasState; + private readonly Ui.Components.InputBar _inputBar; private readonly ILogger _logger; - internal TypingIpc(Plugin plugin, ILogger logger) + internal TypingIpc(Plugin plugin, Ui.Components.InputBar inputBar, ILogger logger) { Plugin = plugin; + _inputBar = inputBar; _logger = logger; StateQueryGate = Plugin.Interface.GetIpcProvider( @@ -62,26 +64,51 @@ internal sealed class TypingIpc : IDisposable private ChatInputState BuildState() { - // Input visibility and focus come back when the new chat layer - // exposes the matching state. The channel type still resolves - // from the active tab so IPC consumers can read it today. var usedChannel = Plugin.CurrentTab.CurrentChannel; var inputChannel = usedChannel.UseTempChannel ? usedChannel.TempChannel : usedChannel.Channel; var channelType = inputChannel.ToChatType(); + // `Plugin` here is the instance property on TypingIpc (TypingIpc.cs:18 + // `private Plugin Plugin { get; }`), NOT the type `HellionChat.Plugin`. + // C# resolves `Plugin.MainWindow` as `this.Plugin.MainWindow` because + // the instance property shadows the type name inside this class. Do + // NOT switch to a type-qualified read (CS0120 — `MainWindow` is an + // instance member, not a static one). MainWindow is resolved in Phase-1 + // (Plugin.cs:295) and never re-assigned, so `this.Plugin.MainWindow` + // is non-null by the time TypingIpc.Update() runs (HostedServices only + // start after Phase-1). The `null!`-suppression on the MainWindow + // property would let us drop the `?.`, but a null-safe read costs + // nothing at runtime and shields against a theoretical pre-Phase-1 + // caller (a future IPC-pull that fires before HostedServices start). + // Defense in depth, no behaviour change for the production path. + var mainWindowOpen = this.Plugin.MainWindow?.IsOpen ?? false; + + // Stale-state guard: InputBar._isFocused is only written in DrawInputField, + // which only runs while MainWindow is open. After the user closes the + // window, _isFocused freezes on the last value. _pendingMessage has the + // same stale problem — it is only cleared in TrySend (successful send) + // or ClearBuffer (manual reset), so closing MainWindow with non-empty + // buffer leaves PendingLength frozen above zero. Without gating all + // four state fields on mainWindowOpen, Cross-Plugin-Subscribers would + // keep seeing InputFocused: true / HasText: true / IsTyping: true / + // TextLength: N even though no input field exists. + var inputFocused = mainWindowOpen && _inputBar.IsFocused; + var hasText = mainWindowOpen && _inputBar.PendingLength > 0; + var textLength = mainWindowOpen ? _inputBar.PendingLength : 0; + return ( - InputVisible: false, - InputFocused: false, - HasText: false, - IsTyping: false, - TextLength: 0, + InputVisible: mainWindowOpen, + InputFocused: inputFocused, + HasText: hasText, + IsTyping: hasText, + TextLength: textLength, ChannelType: channelType ); } - private ChatInputState GetState() => BuildState(); + internal ChatInputState GetState() => BuildState(); internal void Update() { diff --git a/HellionChat/PluginHostFactory.cs b/HellionChat/PluginHostFactory.cs index afcc0d8..5734e2b 100644 --- a/HellionChat/PluginHostFactory.cs +++ b/HellionChat/PluginHostFactory.cs @@ -103,6 +103,7 @@ internal static class PluginHostFactory )); services.AddSingleton(sp => new TypingIpc( sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService>() ));