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).
This commit is contained in:
2026-06-13 15:09:38 +02:00
parent 593eb30c9f
commit ad635c77c1
7 changed files with 197 additions and 5 deletions
+1
View File
@@ -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
@@ -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, (ChatSource, ChatSource)>
{
[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, (ChatSource, ChatSource)>
{
[ChatType.Say] = (ChatSourceExt.All, ChatSourceExt.All),
},
};
public void CleanUp() { }
}
+2 -1
View File
@@ -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(
+2 -1
View File
@@ -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);
@@ -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)
{
+7 -1
View File
@@ -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;
+29 -2
View File
@@ -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)