From ad635c77c1c9fc6756624b8083aae3eeb320180e Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Sat, 13 Jun 2026 15:09:38 +0200 Subject: [PATCH] fix(tell): strip stale tell state on tab activation OnTabActivated clears the runtime tell state the game-side detour leaves on a tab (CurrentChannel tell target + partner label) when a DIFFERENT tab becomes the input surface, so a normal typed line can no longer route as a silent /tell to the old partner. Re-clicking the active tab and tabs carrying their own Tab.TellTarget binding (leg1) are preserved. All four activation paths route through it: Sidebar, TopTabBar, ChannelPopoutPool.TryOpen, and the MainWindow draw-seed. EnsureCurrentChannel becomes a pure derive-helper reached only via OnTabActivated. Adds the TellResetOnActivateStep self-test (step count 29 -> 30). --- HellionChat/Plugin.cs | 1 + .../SelfTests/TellResetOnActivateStep.cs | 149 ++++++++++++++++++ HellionChat/Ui/Components/Sidebar.cs | 3 +- HellionChat/Ui/Components/TopTabBar.cs | 3 +- HellionChat/Ui/Windows/ChannelPopoutPool.cs | 7 + HellionChat/Ui/Windows/MainWindow.cs | 8 +- HellionChat/Util/TabLifecycleHelpers.cs | 31 +++- 7 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 HellionChat/SelfTests/TellResetOnActivateStep.cs diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs index f49a649..4727654 100755 --- a/HellionChat/Plugin.cs +++ b/HellionChat/Plugin.cs @@ -405,6 +405,7 @@ public sealed class Plugin : IAsyncDalamudPlugin new SelfTests.SidebarGreetedGlyphStep(this), new SelfTests.SidebarSectionHeaderStep(this), new SelfTests.ScrollSnapDecisionStep(this), + new SelfTests.TellResetOnActivateStep(), ]); // Re-surface the wizard for existing users when a major UX diff --git a/HellionChat/SelfTests/TellResetOnActivateStep.cs b/HellionChat/SelfTests/TellResetOnActivateStep.cs new file mode 100644 index 0000000..0784f47 --- /dev/null +++ b/HellionChat/SelfTests/TellResetOnActivateStep.cs @@ -0,0 +1,149 @@ +using Dalamud.Bindings.ImGui; +using Dalamud.Game.Text.SeStringHandling; +using Dalamud.Plugin.SelfTest; +using HellionChat.Code; +using HellionChat.GameFunctions.Types; +using HellionChat.Util; + +namespace HellionChat.SelfTests; + +// F1: the activation strip. Drives the REAL OnTabActivated — the entry the +// Sidebar/TopTabBar click handlers, the pop-out path and the Draw-seed all call +// — with local probe tabs (Plugin.Config.Tabs is never touched). Asserts the +// five contracts: strip-on-switch, no-strip-on-reclick (TR-4), leg1 preserve, +// derive, and non-tell untouched. +internal sealed class TellResetOnActivateStep : ISelfTestStep +{ + public string Name => "Hellion Chat - Tell reset on tab activate"; + + public SelfTestStepResult RunStep() + { + var other = MakeSayTab(); + + // (a) switching ONTO a stale-tell tab with no Tab-level binding strips the + // runtime tell state (target + partner label) and re-derives the channel. + var stale = MakeStaleTellTab(boundTellTarget: false, withLabel: true); + TabLifecycleHelpers.OnTabActivated(stale, other); + if (stale.CurrentChannel.TellTarget is not null) + { + ImGui.Text("(a) stale tell target not cleared on switch"); + return SelfTestStepResult.Fail; + } + if (stale.CurrentChannel.Channel != InputChannel.Say) + { + ImGui.Text($"(a) channel not re-derived to Say, got {stale.CurrentChannel.Channel}"); + return SelfTestStepResult.Fail; + } + if (stale.CurrentChannel.Name.Count != 0) + { + ImGui.Text("(a) stale partner label not cleared"); + return SelfTestStepResult.Fail; + } + + // (b) re-clicking the already-active tab (previous == tab) must NOT strip + // a live game-tell conversation (TR-4 regression guard). + var reclick = MakeStaleTellTab(boundTellTarget: false, withLabel: false); + TabLifecycleHelpers.OnTabActivated(reclick, reclick); + if (reclick.CurrentChannel.TellTarget is null) + { + ImGui.Text("(b) re-click wrongly stripped the active tell tab"); + return SelfTestStepResult.Fail; + } + if (reclick.CurrentChannel.Channel != InputChannel.Tell) + { + ImGui.Text("(b) re-click wrongly changed the active tab's channel"); + return SelfTestStepResult.Fail; + } + + // (c) a tab whose own Tab.TellTarget is set is a real binding (leg1): + // channel + runtime target survive a switch. + var bound = MakeStaleTellTab(boundTellTarget: true, withLabel: false); + TabLifecycleHelpers.OnTabActivated(bound, other); + if (bound.CurrentChannel.TellTarget is null) + { + ImGui.Text("(c) bound tell tab wrongly stripped"); + return SelfTestStepResult.Fail; + } + if (bound.CurrentChannel.Channel != InputChannel.Tell) + { + ImGui.Text("(c) bound tell tab channel wrongly changed"); + return SelfTestStepResult.Fail; + } + + // (d) an Invalid-channel tab just derives (pre-existing semantics). + var invalid = MakeSayTab(); + TabLifecycleHelpers.OnTabActivated(invalid, other); + if (invalid.CurrentChannel.Channel != InputChannel.Say) + { + ImGui.Text( + $"(d) invalid-channel tab not derived, got {invalid.CurrentChannel.Channel}" + ); + return SelfTestStepResult.Fail; + } + + // (e) a non-tell tab is left untouched. Seed it with runtime tell state + // AND a label so a guard that wrongly fired on non-tell tabs would null + // them — the channel re-derive alone could not mask that regression. + var say = MakeSayTab(); + say.CurrentChannel.SetChannel(InputChannel.Say); + say.CurrentChannel.TellTarget = new TellTarget("Untouched", 21, 0, TellReason.Direct); + var sayLabel = new SeStringBuilder().AddText("Untouched@World").Build(); + say.CurrentChannel.Name = ChunkUtil + .ToChunks(sayLabel, ChunkSource.Content, ChatType.Say) + .ToList(); + TabLifecycleHelpers.OnTabActivated(say, other); + if (say.CurrentChannel.Channel != InputChannel.Say) + { + ImGui.Text("(e) non-tell tab channel wrongly changed"); + return SelfTestStepResult.Fail; + } + if (say.CurrentChannel.TellTarget is null || say.CurrentChannel.Name.Count == 0) + { + ImGui.Text("(e) non-tell tab runtime state wrongly stripped"); + return SelfTestStepResult.Fail; + } + + return SelfTestStepResult.Pass; + } + + // A tab carrying runtime tell state the way the game-side detour leaves it: + // CurrentChannel.Channel == Tell with a resolvable CurrentChannel.TellTarget, + // optionally with the partner-name label chunks. boundTellTarget controls + // whether the Tab-level TellTarget marks it a real binding (leg1). + private static Tab MakeStaleTellTab(bool boundTellTarget, bool withLabel) + { + var tab = new Tab + { + Name = "selftest-activate-tell", + TellTarget = boundTellTarget + ? new TellTarget("Bound", 21, 0, TellReason.Direct) + : TellTarget.Empty(), + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + tab.CurrentChannel.SetChannel(InputChannel.Tell); + tab.CurrentChannel.TellTarget = new TellTarget("Stale", 21, 0, TellReason.Direct); + if (withLabel) + { + var ss = new SeStringBuilder().AddText("Stale@World").Build(); + tab.CurrentChannel.Name = ChunkUtil + .ToChunks(ss, ChunkSource.Content, ChatType.Say) + .ToList(); + } + return tab; + } + + private static Tab MakeSayTab() => + new() + { + Name = "selftest-activate-say", + SelectedChannels = new Dictionary + { + [ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All), + }, + }; + + public void CleanUp() { } +} diff --git a/HellionChat/Ui/Components/Sidebar.cs b/HellionChat/Ui/Components/Sidebar.cs index 7a969d0..1511b86 100644 --- a/HellionChat/Ui/Components/Sidebar.cs +++ b/HellionChat/Ui/Components/Sidebar.cs @@ -247,8 +247,9 @@ internal sealed class Sidebar var rowHovered = ImGui.IsItemHovered(); if (ImGui.IsItemClicked()) { + var previous = activeTab; activeTab = tab; - TabLifecycleHelpers.EnsureCurrentChannel(tab); + TabLifecycleHelpers.OnTabActivated(tab, previous); } dl.DrawHoverSheen( diff --git a/HellionChat/Ui/Components/TopTabBar.cs b/HellionChat/Ui/Components/TopTabBar.cs index d41da8b..a0148c5 100644 --- a/HellionChat/Ui/Components/TopTabBar.cs +++ b/HellionChat/Ui/Components/TopTabBar.cs @@ -34,8 +34,9 @@ internal sealed class TopTabBar ) ) { + var previous = activeTab; activeTab = tab; - TabLifecycleHelpers.EnsureCurrentChannel(tab); + TabLifecycleHelpers.OnTabActivated(tab, previous); } TabContextMenu.Draw(tab, $"toptab_ctx_{i}", _pool); diff --git a/HellionChat/Ui/Windows/ChannelPopoutPool.cs b/HellionChat/Ui/Windows/ChannelPopoutPool.cs index 5f5b79c..4288348 100644 --- a/HellionChat/Ui/Windows/ChannelPopoutPool.cs +++ b/HellionChat/Ui/Windows/ChannelPopoutPool.cs @@ -1,3 +1,4 @@ +using HellionChat.Util; using Microsoft.Extensions.Logging; namespace HellionChat.Ui.Windows; @@ -40,6 +41,12 @@ internal sealed class ChannelPopoutPool public bool TryOpen(Tab tab) { + // A popped tab gets its own input bar, so strip stale tell state first — + // otherwise a popped-out stale-tell tab would be a send surface that + // bypasses the click-path activation strip. Previous = the main window's + // active tab; popping the active tab itself must not strip (TR-4 guard). + TabLifecycleHelpers.OnTabActivated(tab, Plugin.Instance.MainWindow?.ActiveTab); + var slot = _slots.TryReserve(tab.Identifier); if (slot < 0) { diff --git a/HellionChat/Ui/Windows/MainWindow.cs b/HellionChat/Ui/Windows/MainWindow.cs index e944860..1491814 100644 --- a/HellionChat/Ui/Windows/MainWindow.cs +++ b/HellionChat/Ui/Windows/MainWindow.cs @@ -156,7 +156,13 @@ internal sealed class MainWindow : Window // First-frame seed: the active tab defaults to the first persisted // tab so the message list isn't empty on a clean session. if (_activeTab is null && Plugin.Config.Tabs.Count > 0) - _activeTab = Plugin.Config.Tabs[0]; + { + var seeded = Plugin.Config.Tabs[0]; + _activeTab = seeded; + // The seeded Tabs[0] is the likeliest legacy stale-tell carrier + // (pre-coupling the detour wrote here); strip it like any activation. + TabLifecycleHelpers.OnTabActivated(seeded, null); + } var statusHeight = Components.StatusBar.Height; diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs index 64fdaf6..9c2a6f8 100644 --- a/HellionChat/Util/TabLifecycleHelpers.cs +++ b/HellionChat/Util/TabLifecycleHelpers.cs @@ -17,8 +17,35 @@ internal static class TabLifecycleHelpers public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t); - // Shared by the click paths (Sidebar, TopTabBar) and the keybind tab-cycle - // path so every entry point resolves a tab's channel identically (no drift). + // Stale-tell strip + channel derive, run at every tab activation. When a + // DIFFERENT tab becomes the input surface, drop any runtime tell state the + // game-side detour left on it (the CurrentChannel tell target plus the + // partner-name label) so a normal typed line cannot route as a silent /tell + // to the old partner — the same privacy guard StripTellBindingOnPromote + // applies on promote. Re-activating the already-active tab must NOT strip + // (a live game-tell would lose its context, TR-4); a tab carrying its own + // Tab.TellTarget is a real tell binding (leg1) and is left intact. + internal static void OnTabActivated(Tab tab, Tab? previous) + { + if ( + !ReferenceEquals(tab, previous) + && tab.CurrentChannel.Channel == InputChannel.Tell + && tab.TellTarget?.IsSet() != true + ) + { + tab.CurrentChannel.SetChannel(InputChannel.Invalid); + tab.CurrentChannel.TellTarget = null; + tab.CurrentChannel.ResetTempChannel(); + // Label chunks carry the partner name after a game-side tell. + tab.CurrentChannel.Name = []; + } + + EnsureCurrentChannel(tab); + } + + // Pure derive-helper: resolves a tab's input channel from its + // SelectedChannels when none is set yet. Reached only via OnTabActivated + // now, so the strip and the derive stay in lockstep at every entry. internal static void EnsureCurrentChannel(Tab tab) { if (tab.CurrentChannel.Channel != InputChannel.Invalid)