diff --git a/HellionChat/AutoTellTabsService.cs b/HellionChat/AutoTellTabsService.cs
index 977b77c..59413d3 100644
--- a/HellionChat/AutoTellTabsService.cs
+++ b/HellionChat/AutoTellTabsService.cs
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using Dalamud.Game.Text;
using Dalamud.Game.Text.SeStringHandling;
+using Dalamud.Interface.ImGuiNotification;
using HellionChat.Code;
using HellionChat.GameFunctions.Types;
using HellionChat.Resources;
@@ -20,13 +21,11 @@ internal sealed class AutoTellTabsService : IDisposable
private readonly MessageStore _store;
private readonly object _tempTabsLock = new();
- // F2.1: lock-free counter mirrors Config.Tabs.Count(IsTempTab) so the
- // hot-path getter doesn't contend with HandleTell on every render frame.
- // Bumped from inside the existing mutation paths so it stays consistent
- // with the underlying list — see SpawnTempTab, DropOldestTempTab, OnLogout
- // and ResyncTempTabCounter (used by Plugin.cs snapshot-restore).
- // TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs
- private int _activeTempTabCount;
+ // Hard cap on pinned TempTabs so the sidebar doesn't inflate over years
+ // of usage. Separate pool from AutoTellTabsLimit (15) — pinned tabs live
+ // in their own bucket. A configurable cap is a vault-backlog anchor for
+ // a later cycle if tester feedback demands it.
+ internal const int MaxPinnedTempTabs = 5;
private bool _initialized;
@@ -37,7 +36,14 @@ internal sealed class AutoTellTabsService : IDisposable
_store = store;
}
- internal int ActiveTempTabCount => Volatile.Read(ref _activeTempTabCount);
+ // Derived from the tab list on read. Pin/Unpin/Promote/Logout simply
+ // mutate IsPinned or remove tabs — the count adapts automatically.
+ // Replaces the F2.1 Interlocked counter because the new pin-state
+ // transitions are cold-path and don't need lock-free reads.
+ internal int ActiveTempTabCount =>
+ Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInUnpinnedPool);
+
+ internal int PinnedTempTabCount => Plugin.Config.Tabs.Count(TabLifecycleHelpers.IsInPinnedPool);
internal void Initialize()
{
@@ -46,25 +52,11 @@ internal sealed class AutoTellTabsService : IDisposable
return;
}
- // Seed the counter from the persisted Tabs list so a config that already
- // contains TempTabs from a prior session starts in sync. Plugin.cs:168
- // crash-recovery has already dropped TempTabs by the time we get here,
- // so the snapshot reflects post-recovery reality.
- Interlocked.Exchange(ref _activeTempTabCount, Plugin.Config.Tabs.Count(t => t.IsTempTab));
-
_messageManager.MessageProcessed += HandleTell;
Plugin.ClientState.Logout += OnLogout;
_initialized = true;
}
- // F2.1: callable from outside paths that mutate Config.Tabs directly
- // (Plugin.cs snapshot-restore). Atomically re-pegs the counter to the
- // live IsTempTab count.
- internal void ResyncTempTabCounter()
- {
- Interlocked.Exchange(ref _activeTempTabCount, Plugin.Config.Tabs.Count(t => t.IsTempTab));
- }
-
public void Dispose()
{
if (!_initialized)
@@ -170,12 +162,14 @@ internal sealed class AutoTellTabsService : IDisposable
);
}
- private void DropOldestTempTab()
+ internal void DropOldestTempTab()
{
- // Prioritize greeted tabs for drop; within each bucket, drop by oldest LastActivity
+ // Pinned tabs live in their own bucket (MaxPinnedTempTabs) and are
+ // never drop candidates. They leave the bucket only via Unpin or
+ // PromoteToPermanent.
var victim = Plugin
.Config.Tabs.Select((tab, idx) => (Tab: tab, Index: idx))
- .Where(t => t.Tab.IsTempTab)
+ .Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t.Tab))
.OrderByDescending(t => t.Tab.IsGreeted)
.ThenBy(t => t.Tab.LastActivity)
.FirstOrDefault();
@@ -198,7 +192,6 @@ internal sealed class AutoTellTabsService : IDisposable
}
Plugin.Config.Tabs.RemoveAt(victim.Index);
- Interlocked.Decrement(ref _activeTempTabCount);
// Re-anchor active tab to avoid silent switch when tab is dropped
if (victim.Index <= _plugin.LastTab)
@@ -223,7 +216,6 @@ internal sealed class AutoTellTabsService : IDisposable
}
Plugin.Config.Tabs.Add(tab);
- Interlocked.Increment(ref _activeTempTabCount);
}
private static Tab BuildTempTab(string playerName, uint worldRowId)
@@ -354,14 +346,16 @@ internal sealed class AutoTellTabsService : IDisposable
{
lock (_tempTabsLock)
{
- // Snapshot active tab index before mutating list
+ // Pinned TempTabs must survive char-switch — that's the whole point
+ // of pinning. Only unpinned ones get stripped.
var lastIndex = _plugin.LastTab;
var lastIndexValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
- var currentWasTempTab = lastIndexValid && Plugin.Config.Tabs[lastIndex].IsTempTab;
+ var currentWasUnpinnedTempTab =
+ lastIndexValid
+ && TabLifecycleHelpers.IsInUnpinnedPool(Plugin.Config.Tabs[lastIndex]);
- // Clean up pop-out windows before removing temp tabs
var poppedTempTabIds = Plugin
- .Config.Tabs.Where(t => t.IsTempTab && t.PopOut)
+ .Config.Tabs.Where(t => TabLifecycleHelpers.IsInUnpinnedPool(t) && t.PopOut)
.Select(t => t.Identifier)
.ToList();
if (poppedTempTabIds.Count > 0)
@@ -377,15 +371,68 @@ internal sealed class AutoTellTabsService : IDisposable
}
}
- var removed = Plugin.Config.Tabs.RemoveAll(t => t.IsTempTab);
- Interlocked.Add(ref _activeTempTabCount, -removed);
+ Plugin.Config.Tabs.RemoveAll(TabLifecycleHelpers.IsInUnpinnedPool);
- // Force switch to tab 0 if active tab was temp or index is now out of range
+ // Force switch to tab 0 if active tab was an unpinned temp tab or
+ // index is now out of range. Pinned tabs survive — no switch needed.
var stillValid = lastIndex >= 0 && lastIndex < Plugin.Config.Tabs.Count;
- if (currentWasTempTab || !stillValid)
+ if (currentWasUnpinnedTempTab || !stillValid)
{
_plugin.WantedTab = 0;
}
}
}
+
+ internal bool TryPin(Tab tab)
+ {
+ if (!tab.IsTempTab || tab.IsPinned)
+ {
+ return false;
+ }
+
+ if (PinnedTempTabCount >= MaxPinnedTempTabs)
+ {
+ WrapperUtil.AddNotification(
+ string.Format(HellionStrings.PinTab_LimitReached, MaxPinnedTempTabs),
+ NotificationType.Warning
+ );
+ return false;
+ }
+
+ tab.IsPinned = true;
+ _plugin.SaveConfig();
+ return true;
+ }
+
+ internal void Unpin(Tab tab)
+ {
+ if (!tab.IsPinned)
+ {
+ return;
+ }
+
+ // If the unpinned pool is already full, dropping the oldest before
+ // flipping the flag avoids counting the just-unpinned tab as a drop
+ // candidate.
+ if (ActiveTempTabCount >= Plugin.Config.AutoTellTabsLimit)
+ {
+ DropOldestTempTab();
+ }
+
+ tab.IsPinned = false;
+ _plugin.SaveConfig();
+ }
+
+ internal void PromoteToPermanent(Tab tab)
+ {
+ if (!tab.IsTempTab)
+ {
+ return;
+ }
+
+ tab.IsTempTab = false;
+ tab.IsPinned = false;
+ tab.TellTarget = TellTarget.Empty();
+ _plugin.SaveConfig();
+ }
}
diff --git a/HellionChat/Configuration.cs b/HellionChat/Configuration.cs
index 47de533..7b4361a 100755
--- a/HellionChat/Configuration.cs
+++ b/HellionChat/Configuration.cs
@@ -34,7 +34,7 @@ public class ConfigKeyBind
[Serializable]
public class Configuration : IPluginConfiguration
{
- private const int LatestVersion = 16;
+ private const int LatestVersion = 17;
public int Version { get; set; } = LatestVersion;
@@ -278,16 +278,17 @@ public class Configuration : IPluginConfiguration
ColorSelectedInputChannelButton = other.ColorSelectedInputChannelButton;
// Keep live temp tabs alive across UpdateFrom — a settings save must
- // not destroy open tell conversations. For persistent tabs, capture
- // the live MessageList and LastSendUnread by Identifier before the
- // replace and restore them onto the freshly cloned tabs; new tabs
- // get an empty MessageList, deleted tabs lose their history (intended).
- var liveTempTabs = Tabs.Where(t => t.IsTempTab).ToList();
- var livePersistentSession = Tabs.Where(t => !t.IsTempTab)
+ // not destroy open tell conversations. Pinned TempTabs are persistent
+ // and come through `other` like regular tabs; unpinned TempTabs are
+ // session-only and held from the local state. For persistent tabs
+ // (incl. pinned), capture live MessageList + LastSendUnread by
+ // Identifier and restore them onto the freshly cloned tabs.
+ var liveUnpinnedTempTabs = Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
+ var livePersistentSession = Tabs.Where(t => !TabLifecycleHelpers.IsInUnpinnedPool(t))
.ToDictionary(t => t.Identifier, t => (t.Messages, t.LastSendUnread));
Tabs = other
- .Tabs.Where(t => !t.IsTempTab)
+ .Tabs.Where(t => !t.IsTempTab || t.IsPinned)
.Select(t =>
{
var clone = t.Clone();
@@ -299,7 +300,7 @@ public class Configuration : IPluginConfiguration
return clone;
})
.ToList();
- Tabs.AddRange(liveTempTabs);
+ Tabs.AddRange(liveUnpinnedTempTabs);
ChatTabForward = other.ChatTabForward;
ChatTabBackward = other.ChatTabBackward;
@@ -404,6 +405,11 @@ public class Tab
public bool HideWhenInactive;
public bool IsTempTab;
+
+ // Pinned TempTabs survive plugin reload and logout — tester feedback from
+ // Jin (v1.4.7). Pinned tabs live in their own pool (MaxPinnedTempTabs)
+ // separate from the AutoTellTabsLimit bucket.
+ public bool IsPinned;
public bool AllSenderMessages;
public TellTarget TellTarget = TellTarget.Empty();
@@ -511,6 +517,7 @@ public class Tab
HideInBattle = HideInBattle,
HideWhenInactive = HideWhenInactive,
IsTempTab = IsTempTab,
+ IsPinned = IsPinned,
AllSenderMessages = AllSenderMessages,
TellTarget = TellTarget.Clone(),
IsGreeted = IsGreeted,
diff --git a/HellionChat/Plugin.cs b/HellionChat/Plugin.cs
index 262691d..ea1594b 100755
--- a/HellionChat/Plugin.cs
+++ b/HellionChat/Plugin.cs
@@ -169,20 +169,22 @@ public sealed class Plugin : IAsyncDalamudPlugin
PlatformUtil = new DalamudPlatformUtil();
LogProxy = new DalamudPluginLogProxy(Log);
- // Schema gate: v1.4.x requires config v16. Users on older schemas
- // must install v1.4.2 first to run the migration chain.
+ // Schema gate: v1.4.x requires config v16+. Users on older schemas
+ // must install v1.4.2 first to run the migration chain. v17 adds
+ // Tab.IsPinned (additive, no data migration needed) so v16 configs
+ // load cleanly and get their Version stamp bumped after the gate.
if (Config.Version < 16)
{
throw new InvalidOperationException(
- $"HellionChat v1.4.6 requires config schema v16, got v{Config.Version}. "
- + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.6."
+ $"HellionChat v1.4.7 requires config schema v16, got v{Config.Version}. "
+ + "Please install v1.4.2 first to migrate the configuration, then upgrade to v1.4.7."
);
}
+ Config.Version = 17;
- // Session-only tabs are stripped on every load; AutoTellTabsService.Initialize
- // then re-pegs TempTabCounter from the stripped list, not the pre-strip snapshot.
- // TEST-MIRROR: ../../Hellion Build test/_Helpers/TempTabCounterTests.cs
- Config.Tabs.RemoveAll(t => t.IsTempTab);
+ // Unpinned TempTabs are session-only and dropped on every load. Pinned
+ // TempTabs survive reload — Jin's tester feedback (v1.4.7).
+ Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnLoad);
LanguageChanged(Interface.UiLanguage);
ImGuiUtil.Initialize(this);
@@ -652,21 +654,17 @@ public sealed class Plugin : IAsyncDalamudPlugin
internal void SaveConfig()
{
- // Session-only Auto-Tell-Tabs aren't persisted, so they move aside
- // before serialization and re-attach after. Cloning only the temp
- // subset keeps the allocation proportional to AutoTellTabsLimit
- // (<=15) instead of the full tab list.
- var tempTabs = Config.Tabs.Where(t => t.IsTempTab).ToList();
- Config.Tabs.RemoveAll(t => t.IsTempTab);
+ // Only unpinned TempTabs are session-only — they move aside before
+ // serialization and re-attach after. Pinned TempTabs stay in
+ // Config.Tabs across the save so JSON includes them. Cloning only the
+ // unpinned subset keeps the allocation proportional to
+ // AutoTellTabsLimit (<=15) instead of the full tab list.
+ var unpinnedTempTabs = Config.Tabs.Where(TabLifecycleHelpers.IsInUnpinnedPool).ToList();
+ Config.Tabs.RemoveAll(TabLifecycleHelpers.ShouldStripOnSave);
Interface.SavePluginConfig(Config);
- Config.Tabs.AddRange(tempTabs);
-
- // F2.1: the mid-step RemoveAll bypasses AutoTellTabsService, so
- // re-peg the counter. Null-conditional because SaveConfig can fire
- // before Phase-2 init.
- AutoTellTabsService?.ResyncTempTabCounter();
+ Config.Tabs.AddRange(unpinnedTempTabs);
}
internal void LanguageChanged(string langCode)
diff --git a/HellionChat/Resources/HellionStrings.Designer.cs b/HellionChat/Resources/HellionStrings.Designer.cs
index 4ac3105..1e9e617 100644
--- a/HellionChat/Resources/HellionStrings.Designer.cs
+++ b/HellionChat/Resources/HellionStrings.Designer.cs
@@ -170,6 +170,12 @@ internal class HellionStrings
internal static string AutoTellTabs_HistoryLoadError => Get(nameof(AutoTellTabs_HistoryLoadError));
internal static string AutoTellTabs_GreetedTooltip => Get(nameof(AutoTellTabs_GreetedTooltip));
internal static string AutoTellTabs_UnGreetedTooltip => Get(nameof(AutoTellTabs_UnGreetedTooltip));
+ internal static string PinTab_MenuPin => Get(nameof(PinTab_MenuPin));
+ internal static string PinTab_MenuUnpin => Get(nameof(PinTab_MenuUnpin));
+ internal static string PinTab_MenuPromote => Get(nameof(PinTab_MenuPromote));
+ internal static string PinTab_PromoteTooltip => Get(nameof(PinTab_PromoteTooltip));
+ internal static string PinTab_LimitReached => Get(nameof(PinTab_LimitReached));
+ internal static string PinTab_PinnedTooltip => Get(nameof(PinTab_PinnedTooltip));
// Hellion Chat — Auto-Tell-Tabs Chat settings tab
internal static string ChatLog_AutoTellTabs_Section_Title => Get(nameof(ChatLog_AutoTellTabs_Section_Title));
diff --git a/HellionChat/Resources/HellionStrings.de.resx b/HellionChat/Resources/HellionStrings.de.resx
index 687d439..602dbda 100644
--- a/HellionChat/Resources/HellionStrings.de.resx
+++ b/HellionChat/Resources/HellionStrings.de.resx
@@ -383,6 +383,24 @@
Als begrüßt markieren.
+
+ Tab anpinnen
+
+
+ Tab lösen
+
+
+ Dauerhaft behalten
+
+
+ „Dauerhaft behalten" macht aus dem Tab einen regulären Tab, der zu allen channel-gefilterten Nachrichten passt — bei Bedarf danach umbenennen.
+
+
+ Maximal {0} angepinnte Tell-Tabs erreicht. Erst einen lösen oder dauerhaft behalten.
+
+
+ Angepinnt — überlebt Relog.
+
@@ -398,7 +416,7 @@
Maximale Anzahl der Auto-Tell-Tabs
- Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell.
+ Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell. Diese Grenze gilt nur für den automatisch verwalteten Pool. Angepinnte Tell-Tabs (Rechtsklick → Tab anpinnen) leben in einem separaten Pool von bis zu 5 Tabs und überleben Relog.
Kompakte Anzeige
diff --git a/HellionChat/Resources/HellionStrings.resx b/HellionChat/Resources/HellionStrings.resx
index 0c736ea..5fb3d71 100644
--- a/HellionChat/Resources/HellionStrings.resx
+++ b/HellionChat/Resources/HellionStrings.resx
@@ -383,6 +383,24 @@
Mark as greeted.
+
+ Pin Tab
+
+
+ Unpin Tab
+
+
+ Promote to permanent
+
+
+ Promote turns this into a regular tab matching all channel-filtered messages — rename afterwards if needed.
+
+
+ Maximum of {0} pinned tell tabs reached. Unpin one first, or use Promote to permanent.
+
+
+ Pinned — survives relog.
+
@@ -398,7 +416,7 @@
Maximum number of auto-tell tabs
- When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell.
+ When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell. This limit applies to the auto-managed pool. Pinned tell tabs (right-click → Pin Tab) live in a separate pool of up to 5 and survive relog.
Compact display
diff --git a/HellionChat/Ui/ChatLogWindow.cs b/HellionChat/Ui/ChatLogWindow.cs
index fb90cc5..e120544 100644
--- a/HellionChat/Ui/ChatLogWindow.cs
+++ b/HellionChat/Ui/ChatLogWindow.cs
@@ -2124,10 +2124,56 @@ public sealed class ChatLogWindow : Window
anyChanged = true;
}
+ if (tab.IsTempTab)
+ {
+ ImGui.Separator();
+ DrawPinControls(tab);
+ }
+
if (anyChanged)
Plugin.SaveConfig();
}
+ private void DrawPinControls(Tab tab)
+ {
+ var svc = Plugin.AutoTellTabsService;
+ if (svc == null)
+ return;
+
+ if (tab.IsPinned)
+ {
+ if (ImGui.MenuItem(HellionStrings.PinTab_MenuUnpin))
+ {
+ svc.Unpin(tab);
+ ImGui.CloseCurrentPopup();
+ }
+ }
+ else
+ {
+ var atCap = svc.PinnedTempTabCount >= AutoTellTabsService.MaxPinnedTempTabs;
+ if (ImGui.MenuItem(HellionStrings.PinTab_MenuPin, enabled: !atCap))
+ {
+ if (svc.TryPin(tab))
+ ImGui.CloseCurrentPopup();
+ }
+ if (atCap && ImGui.IsItemHovered(ImGuiHoveredFlags.AllowWhenDisabled))
+ ImGui.SetTooltip(
+ string.Format(
+ HellionStrings.PinTab_LimitReached,
+ AutoTellTabsService.MaxPinnedTempTabs
+ )
+ );
+ }
+
+ if (ImGui.MenuItem(HellionStrings.PinTab_MenuPromote))
+ {
+ svc.PromoteToPermanent(tab);
+ ImGui.CloseCurrentPopup();
+ }
+ if (ImGui.IsItemHovered())
+ ImGui.SetTooltip(HellionStrings.PinTab_PromoteTooltip);
+ }
+
internal readonly List PopOutDocked = [];
internal readonly HashSet PopOutWindows = [];
diff --git a/HellionChat/Util/TabLifecycleHelpers.cs b/HellionChat/Util/TabLifecycleHelpers.cs
new file mode 100644
index 0000000..058bdfb
--- /dev/null
+++ b/HellionChat/Util/TabLifecycleHelpers.cs
@@ -0,0 +1,16 @@
+namespace HellionChat.Util;
+
+// Pure predicates for the TempTab pin lifecycle. Extracted from the strip
+// sites in Plugin.cs and Configuration.cs so they stay in lockstep — a
+// load-time strip that disagrees with the save-time strip is exactly how
+// pinned tabs would silently fall out of the JSON.
+internal static class TabLifecycleHelpers
+{
+ public static bool IsInUnpinnedPool(Tab t) => t.IsTempTab && !t.IsPinned;
+
+ public static bool IsInPinnedPool(Tab t) => t.IsTempTab && t.IsPinned;
+
+ public static bool ShouldStripOnLoad(Tab t) => IsInUnpinnedPool(t);
+
+ public static bool ShouldStripOnSave(Tab t) => IsInUnpinnedPool(t);
+}