diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs
index ccdf8f7b..777739d5 100644
--- a/src/AcDream.App/UI/Layout/ChatWindowController.cs
+++ b/src/AcDream.App/UI/Layout/ChatWindowController.cs
@@ -52,6 +52,13 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
private const uint RootId = 0x10000600u; // gmFloatyMainChatUI window root, 410x100
private const uint TranscriptPanelId = 0x10000010u;
private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory
+
+ ///
+ /// Retail's "there is new text you have not seen" indicator, shown only
+ /// while the transcript is scrolled off the bottom
+ /// (ChatInterface::ListenToElementMessage @0x004F51C0).
+ ///
+ private const uint UnreadIndicatorId = 0x1000048Cu;
private const uint TrackId = 0x10000012u;
private const uint InputBarId = 0x10000013u;
private const uint MenuId = 0x10000014u;
@@ -143,6 +150,14 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
///
private readonly List?>
_cachedTranscriptTags = new();
+
+ private UiElement? _unreadIndicator;
+
+ ///
+ /// Set when a line arrives while the transcript is scrolled up, cleared
+ /// the moment the view is back at the bottom.
+ ///
+ private bool _hasUnseenText;
private long _cachedTranscriptRevision = -1;
private ulong _cachedFilter;
private float _cachedTranscriptWrapWidth = float.NaN;
@@ -353,6 +368,15 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// @0x004CCE10 -> ChatInterface::StartTell @0x004F41F0.
c.Transcript.OnCharClick = pos => c.TryStartTellFromTag(pos);
+ // ── Unread indicator ─────────────────────────────────────────────
+ c._unreadIndicator = layout.FindElement(UnreadIndicatorId);
+ if (c._unreadIndicator is not null)
+ {
+ c._unreadIndicator.Visible = false;
+ if (c._unreadIndicator is UiButton unread)
+ unread.OnClick = c.ScrollToNewestAndClearUnread;
+ }
+
// ── Input ────────────────────────────────────────────────────────
// Editable/selectable/one-line semantics and state sprites came from the
// imported property/state bags. The controller supplies runtime services only.
@@ -781,6 +805,16 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
return _cachedTranscriptLines;
}
+ // Sampled BEFORE the rebuild, exactly as retail samples IsAtVerticalEnd
+ // before the new line lands: if the player had scrolled up, the
+ // arriving text is unseen and the view must NOT be yanked down.
+ if (_cachedTranscriptRevision != revision
+ && _cachedTranscriptRevision >= 0
+ && !Transcript.Scroll.AtEnd)
+ {
+ _hasUnseenText = true;
+ }
+
var detailed = vm.RecentLinesDetailed();
if (detailed.Count == 0)
{
@@ -856,6 +890,29 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
return false;
}
+ ///
+ /// Jumps to the newest text and clears the unread flag — retail's own
+ /// handler for a click on the indicator.
+ ///
+ internal void ScrollToNewestAndClearUnread()
+ {
+ Transcript.Scroll.ScrollToEnd();
+ _hasUnseenText = false;
+ }
+
+ ///
+ /// Keeps the indicator in step with the view. Reaching the bottom by ANY
+ /// means clears it — the wheel, the scrollbar, or the click above — so it
+ /// cannot be left lit over text the player has already read.
+ ///
+ internal void UpdateUnreadIndicator()
+ {
+ if (Transcript.Scroll.AtEnd)
+ _hasUnseenText = false;
+ if (_unreadIndicator is not null)
+ _unreadIndicator.Visible = _hasUnseenText;
+ }
+
/// Aims the chat entry at and focuses it.
internal void StartTell(string name)
{
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index faad50d6..e666c753 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -861,6 +861,10 @@ public sealed class RetailUiRuntime : IDisposable
NegativeEffectsController?.Tick();
LinkStatusUiController?.Tick();
IndicatorBarController?.Tick();
+ // Campaign CT slice C1: keeps the "unseen text" indicator in step with
+ // the transcript's scroll position, so reaching the bottom by ANY
+ // means clears it.
+ _chatWindowController?.UpdateUnreadIndicator();
JumpPowerbarController?.Tick();
SecureTradeController?.Tick();
SelectedObjectController?.Tick(deltaSeconds);
diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
index 46f7d5c4..7cf98d0d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs
@@ -158,6 +158,48 @@ public class ChatWindowControllerTests
Assert.NotNull(ctrl);
}
+ // ── CT-C1: the unseen-text indicator ────────────────────────────────
+
+ private static ChatWindowController BindController()
+ {
+ var (rootInfo, layout, vm) = BuildTestTree();
+ var bus = new CaptureBus();
+ ChatWindowController? ctrl = ChatWindowController.Bind(
+ rootInfo, layout, vm, () => bus, new ChatWindowState(), null, null, NoTex);
+ Assert.NotNull(ctrl);
+ return ctrl!;
+ }
+
+ [Fact]
+ public void ReachingTheBottomClearsTheUnseenFlagHoweverYouGotThere()
+ {
+ // Retail clears on arrival at the bottom, not only on clicking the
+ // indicator — otherwise scrolling down with the wheel would leave it
+ // lit over text the player has just read.
+ ChatWindowController ctrl = BindController();
+ ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
+ ctrl.Transcript.Scroll.SetScrollY(0); // scrolled up
+ Assert.False(ctrl.Transcript.Scroll.AtEnd);
+
+ ctrl.Transcript.Scroll.ScrollToEnd();
+ ctrl.UpdateUnreadIndicator();
+
+ Assert.True(ctrl.Transcript.Scroll.AtEnd);
+ }
+
+ [Fact]
+ public void ClickingTheIndicatorJumpsToTheNewestText()
+ {
+ ChatWindowController ctrl = BindController();
+ ctrl.Transcript.Scroll.SetExtents(contentHeight: 500, viewHeight: 100);
+ ctrl.Transcript.Scroll.SetScrollY(0);
+ Assert.False(ctrl.Transcript.Scroll.AtEnd);
+
+ ctrl.ScrollToNewestAndClearUnread();
+
+ Assert.True(ctrl.Transcript.Scroll.AtEnd);
+ }
+
[Fact]
public void StartTell_PrefillsTheEntryAndPutsTheCaretAtTheEnd()
{