feat(tabs): add IsPinned with separate pool and 5-tab cap

Tester-Request from Jin (2026-05-03): TempTabs should be pinnable so a
key conversation partner survives a relog. Right-click a TempTab and
choose Pin Tab / Unpin Tab / Promote to permanent.

Pool semantics:
- AutoTellTabsLimit (15) still gates the auto-managed unpinned pool.
- Pinned TempTabs live in their own pool, hard-capped at 5.
- The 6th pin attempt fails with a notification; users can unpin first
  or promote to permanent.
- Unpinning into a full unpinned pool drops the oldest unpinned (no
  user friction).

Mechanics:
- Tab.IsPinned (default false); Tab.Clone() carries it.
- Migration v16 -> v17 (additive; existing tabs default to unpinned).
- Three strip-sites synchronised through TabLifecycleHelpers:
  Plugin.cs load-time, Plugin.SaveConfig, Configuration.UpdateFrom.
- AutoTellTabsService:
  * MaxPinnedTempTabs constant.
  * F2.1 _activeTempTabCount counter retired — ActiveTempTabCount is
    now Tabs.Count(predicate). Pin/Unpin/Promote transitions are
    cold-path and don't need lock-free reads.
  * DropOldestTempTab filters on IsInUnpinnedPool so pinned tabs are
    never drop candidates.
  * OnLogout strips only the unpinned pool; pinned popouts and the
    active-tab switch behave correspondingly.
  * TryPin / Unpin / PromoteToPermanent service methods.
- ChatLogWindow tab context menu: Pin / Unpin / Promote with disabled-
  state at-cap tooltip + Promote tooltip explaining the channel-filter
  side effect.
- HellionStrings (EN+DE) for menu labels, tooltips, the limit warning.
- AutoTellTabsLimit slider description now flags the separate pinned
  pool so users aren't surprised by 18 tabs when the limit reads 15.
This commit is contained in:
2026-05-13 09:06:14 +02:00
parent fee2459e73
commit fd5f970a8b
8 changed files with 222 additions and 66 deletions
+82 -35
View File
@@ -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();
}
}
+16 -9
View File
@@ -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,
+18 -20
View File
@@ -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)
+6
View File
@@ -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));
+19 -1
View File
@@ -383,6 +383,24 @@
<data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve">
<value>Als begrüßt markieren.</value>
</data>
<data name="PinTab_MenuPin" xml:space="preserve">
<value>Tab anpinnen</value>
</data>
<data name="PinTab_MenuUnpin" xml:space="preserve">
<value>Tab lösen</value>
</data>
<data name="PinTab_MenuPromote" xml:space="preserve">
<value>Dauerhaft behalten</value>
</data>
<data name="PinTab_PromoteTooltip" xml:space="preserve">
<value>„Dauerhaft behalten" macht aus dem Tab einen regulären Tab, der zu allen channel-gefilterten Nachrichten passt — bei Bedarf danach umbenennen.</value>
</data>
<data name="PinTab_LimitReached" xml:space="preserve">
<value>Maximal {0} angepinnte Tell-Tabs erreicht. Erst einen lösen oder dauerhaft behalten.</value>
</data>
<data name="PinTab_PinnedTooltip" xml:space="preserve">
<value>Angepinnt — überlebt Relog.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Chat-Einstellungstab) -->
<data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve">
@@ -398,7 +416,7 @@
<value>Maximale Anzahl der Auto-Tell-Tabs</value>
</data>
<data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve">
<value>Beim Erreichen werden begrüßte Tabs mit der ältesten Aktivität zuerst geschlossen. Änderungen greifen beim nächsten /tell.</value>
<value>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.</value>
</data>
<data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve">
<value>Kompakte Anzeige</value>
+19 -1
View File
@@ -383,6 +383,24 @@
<data name="AutoTellTabs_UnGreetedTooltip" xml:space="preserve">
<value>Mark as greeted.</value>
</data>
<data name="PinTab_MenuPin" xml:space="preserve">
<value>Pin Tab</value>
</data>
<data name="PinTab_MenuUnpin" xml:space="preserve">
<value>Unpin Tab</value>
</data>
<data name="PinTab_MenuPromote" xml:space="preserve">
<value>Promote to permanent</value>
</data>
<data name="PinTab_PromoteTooltip" xml:space="preserve">
<value>Promote turns this into a regular tab matching all channel-filtered messages — rename afterwards if needed.</value>
</data>
<data name="PinTab_LimitReached" xml:space="preserve">
<value>Maximum of {0} pinned tell tabs reached. Unpin one first, or use Promote to permanent.</value>
</data>
<data name="PinTab_PinnedTooltip" xml:space="preserve">
<value>Pinned — survives relog.</value>
</data>
<!-- Hellion Chat — Auto-Tell-Tabs (Chat settings tab) -->
<data name="ChatLog_AutoTellTabs_Section_Title" xml:space="preserve">
@@ -398,7 +416,7 @@
<value>Maximum number of auto-tell tabs</value>
</data>
<data name="ChatLog_AutoTellTabs_Limit_Description" xml:space="preserve">
<value>When the limit is reached, greeted tabs with the oldest activity are closed first. Changes take effect on the next /tell.</value>
<value>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.</value>
</data>
<data name="ChatLog_AutoTellTabs_Compact_Name" xml:space="preserve">
<value>Compact display</value>
+46
View File
@@ -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<bool> PopOutDocked = [];
internal readonly HashSet<Guid> PopOutWindows = [];
+16
View File
@@ -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);
}