From 799fdb67cc14b99d020fd2f43a7ec1ffd5c83e02 Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Wed, 13 May 2026 09:53:27 +0200 Subject: [PATCH] fix(tabs): rehydrate pinned TempTab tell-targets after reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoke-test (Jin's scenario) surfaced two coupled bugs in v1.4.7 pin persistence: 1. The chat input couldn't send to the pinned partner after a reload. Tab.CurrentChannel is NonSerialized, so it came back as a fresh UsedChannel with TellTarget=null even though tab.TellTarget (the persisted twin) was intact. The game-side channel hook only repaints CurrentChannel on a /tell or channel switch, so the pinned tab sat there mute until the user manually re-bounced the channel. 2. An incoming tell from the pinned partner spawned a *second* TempTab instead of routing into the existing pinned one. The Name+World lookup in FindTempTab was vulnerable to any round-trip nuance on tab.TellTarget — the fallback path now matches by tab name, which FormatTabName pins at spawn time. Fix: - AutoTellTabsService.Initialize now calls RehydratePinnedTabs() after the Phase-2 wiring lands, seeding tab.CurrentChannel.TellTarget + Channel from the persisted tab.TellTarget. Channel is also defaulted to InputChannel.Tell on the tab record so the chat-input bar paints Tell mode immediately on first selection. - FindTempTab gained a Name-based fallback for the case where the primary TellTarget lookup misses (e.g. a pinned tab whose TellTarget didn't round-trip cleanly through an old save). - HandleTell self-heals: when the fallback matches a pinned tab with a missing TellTarget, the tab is repaired from the live partner data and persisted, so subsequent messages take the fast path. Build-suite coverage was attempted (PinnedTabJsonRoundtripTests) but Tab + TellTarget are both Dalamud-coupled — Newtonsoft's reflection walk loads Dalamud.dll which isn't available in the xUnit AppDomain (documented in feedback_dalamud_test_isolation). Verification stays on the ingame smoke path. --- HellionChat/AutoTellTabsService.cs | 55 ++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 59413d3..340e309 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -52,11 +52,33 @@ internal sealed class AutoTellTabsService : IDisposable return; } + // Pinned tabs come out of the JSON with TellTarget set but + // CurrentChannel reset (NonSerialized). Without re-seeding, the chat + // input has no tell-target on the active pinned tab, and the + // game-side channel hook only repaints CurrentChannel once the user + // triggers a /tell or channel switch. + RehydratePinnedTabs(); + _messageManager.MessageProcessed += HandleTell; Plugin.ClientState.Logout += OnLogout; _initialized = true; } + private static void RehydratePinnedTabs() + { + foreach (var tab in Plugin.Config.Tabs) + { + if (!TabLifecycleHelpers.IsInPinnedPool(tab)) + continue; + if (tab.TellTarget is null || !tab.TellTarget.IsSet()) + continue; + + tab.Channel ??= InputChannel.Tell; + tab.CurrentChannel.Channel = InputChannel.Tell; + tab.CurrentChannel.TellTarget = tab.TellTarget.Clone(); + } + } + public void Dispose() { if (!_initialized) @@ -102,7 +124,23 @@ internal sealed class AutoTellTabsService : IDisposable var existing = FindTempTab(partner.Value.Name, partner.Value.World); if (existing != null) { - // Already routed via MessageManager pipeline + // Already routed via MessageManager pipeline. Repair the + // tell-target if the fallback hit a pinned tab whose + // TellTarget didn't survive a previous round-trip — keeps + // FindTempTab fast on the next message. + if ( + existing.IsPinned + && (existing.TellTarget is null || !existing.TellTarget.IsSet()) + ) + { + existing.TellTarget = new TellTarget( + partner.Value.Name, + partner.Value.World, + 0, + TellReason.Direct + ); + _plugin.SaveConfig(); + } return; } @@ -152,14 +190,25 @@ internal sealed class AutoTellTabsService : IDisposable return null; } - private Tab? FindTempTab(string name, uint world) + private static Tab? FindTempTab(string name, uint world) { - return Plugin.Config.Tabs.FirstOrDefault(t => + var byTarget = Plugin.Config.Tabs.FirstOrDefault(t => t.IsTempTab && t.TellTarget != null && string.Equals(t.TellTarget.Name, name, StringComparison.OrdinalIgnoreCase) && t.TellTarget.World == world ); + if (byTarget != null) + return byTarget; + + // Fallback: match by tab name. Pinned tabs are named via + // FormatTabName(player, world) at spawn time, so the name is a + // stable secondary key when TellTarget didn't survive a save/load + // (older configs from a renamed pin, malformed migrations, etc.). + var expectedName = FormatTabName(name, world); + return Plugin.Config.Tabs.FirstOrDefault(t => + t.IsTempTab && string.Equals(t.Name, expectedName, StringComparison.OrdinalIgnoreCase) + ); } internal void DropOldestTempTab()