diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs index 830ddbb..ae1bb20 100644 --- a/HellionChat/AutoTellTabsService.cs +++ b/HellionChat/AutoTellTabsService.cs @@ -218,7 +218,7 @@ internal sealed class AutoTellTabsService : IDisposable return null; } - private static Tab? FindTempTab(string name, uint world) + internal static Tab? FindTempTab(string name, uint world) { var byTarget = Plugin.Config.Tabs.FirstOrDefault(t => t.IsTempTab @@ -256,21 +256,20 @@ internal sealed class AutoTellTabsService : IDisposable return; } - // Pop-out-window cleanup is offline while the channel-popout pool - // is rebuilt — Tab.PopOut still flips on/off, the visible window - // disappears once the new pool comes online. - var dropped = victim.Tab; Plugin.Config.Tabs.RemoveAt(victim.Index); - // Re-anchor the UI selection if it pointed at the dropped tab. This runs on - // the PendingMessage worker thread and the repair mutates the re-seeded - // tab's channel via OnTabActivated, so marshal it onto the framework thread - // to serialize with Draw (reference_dalamud_framework_thread) — otherwise a - // half-applied strip could race the input bar's send-routing read. + // Re-anchor the UI selection if it pointed at the dropped tab, and close any + // pop-out window the dropped tab owned. Both run on the PendingMessage worker + // thread and touch window state the Draw path reads (OnTabActivated re-seed + + // the pool's Unbind), so marshal onto the framework thread to serialize with + // Draw (reference_dalamud_framework_thread). TryClose is idempotent: a tab that + // was never popped is a silent no-op. Plugin.Framework.RunOnFrameworkThread(() => - _plugin.MainWindow?.ResetActiveTabIfRemoved(dropped) - ); + { + _plugin.ChannelPopoutPool.TryClose(dropped.Identifier); + _plugin.MainWindow?.ResetActiveTabIfRemoved(dropped); + }); } private void SpawnTempTab((string Name, uint World) partner, Message currentMessage) @@ -282,13 +281,28 @@ internal sealed class AutoTellTabsService : IDisposable tab.AddMessage(currentMessage, unread: true); - // Open as pop-out if configured (set before Tabs.Add for next render-tick) + // Open as pop-out if configured (flag set before Tabs.Add for the next render-tick). if (Plugin.Config.AutoTellTabsOpenAsPopout) { tab.PopOut = true; } Plugin.Config.Tabs.Add(tab); + + // Actually open the pop-out window for the flagged tab — without this the + // flag was dead (a PopOut tab with no window). SpawnTempTab runs on the + // PendingMessage worker thread under _tempTabsLock; TryOpen does + // OnTabActivated + Bind (window state Draw reads), so marshal onto the + // framework thread. If the pool is full, drop the flag so it never claims a + // window it didn't get (flag/window parity). + if (tab.PopOut) + { + Plugin.Framework.RunOnFrameworkThread(() => + { + if (!_plugin.ChannelPopoutPool.TryOpen(tab)) + tab.PopOut = false; + }); + } } private static Tab BuildTempTab(string playerName, uint worldRowId) @@ -427,8 +441,12 @@ internal sealed class AutoTellTabsService : IDisposable .Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut) .Select(t => t.Identifier) .ToList(); - // Pop-out-window cleanup is offline; see Disconnect path above. - _ = poppedTempTabIds; + + // Close each popped temp tab's window before the tabs leave the list. + // Logout is a framework-thread event (serialized with Draw), so no + // marshalling is needed here, unlike the worker-thread eviction path. + foreach (var id in poppedTempTabIds) + _plugin.ChannelPopoutPool.TryClose(id); Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);