Rework the chat window#7098
Conversation
|
@CodeRabbit review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (4)
lua/ui/game/chat/ChatController.lua (2)
343-345:LOCFmutatesmsg.textin place; ensure no other receiver re-processes the samemsg.If the same
msgtable reference were ever passed throughOnReceivemore than once (e.g., a future sync replay handler also feeding the same payload), the second pass wouldLOCFan already-formatted string with the originalArgsand produce garbage. The current dedupe inOnSyncChatMessageskeeps this safe today, but it relies on the dedupe set being populated correctly. Consider stampingmsg.textonly after aArgs = nilclear, or formatting into a local variable instead of mutating the payload, so the invariant is enforced rather than implicit.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatController.lua` around lines 343 - 345, LOCF mutates msg.text in place which can corrupt reprocessed messages; in the handler that calls LOCF (the OnReceive path that currently sets msg.text = LOCF(msg.text, unpack(msg.Args))), avoid mutating the incoming payload: either format into a local variable (e.g., local formatted = LOCF(msg.text, unpack(msg.Args)) and use that for downstream display) or call LOCF and then immediately clear msg.Args (msg.Args = nil) before writing back to msg.text so subsequent passes won't re-format; update the code around LOCF usage and ensure OnSyncChatMessages / OnReceive consumers use the non-mutated msg or the cleared Args invariant.
233-245:GetArmyDatamutates the engine's armies table during nickname lookups.Line 240 writes
v.ArmyID = idirectly onto the table returned byGetArmiesTable(). If that table is shared or cached by the engine, this leaks state. The fix is trivial — wrap into a returned copy or passiback via a separate return — but since the mutation is idempotent (always the same value for a given nickname), the impact is bounded. Worth confirming this doesn't trip anything that comparesArmyIDfor entries that were never looked up by nickname.📝 Possible cleanup
local function GetArmyData(army) local armies = GetArmiesTable() if type(army) == 'number' then return armies.armiesTable[army] elseif type(army) == 'string' then for i, v in armies.armiesTable do if v.nickname == army then - v.ArmyID = i - return v + local copy = table.copy(v) + copy.ArmyID = i + return copy end end end end🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatController.lua` around lines 233 - 245, GetArmyData currently mutates the engine's armies table by assigning v.ArmyID = i when looking up by nickname; instead avoid touching the original table — either return a shallow copy of the army entry with ArmyID set, or return the found entry and the index i as a second return value (e.g., return armyCopy, i) so callers can use the ID without altering GetArmiesTable() data; update the function GetArmyData and any callers to handle the new return shape accordingly.lua/ui/game/chat/ChatLinesInterface.lua (2)
264-281: Minor: per-lineSetFontSizeruns beforeRebuildPoolmay destroy some lines.Line 267–269 sets the font on every existing line, then
RebuildPoolat line 272 may destroy a tail of those lines if the new row height implies fewer slots. The wasted setup is harmless but inefficient. Reordering —RebuildPoolfirst, then iterating the surviving rows — avoids work on destroyed controls.♻️ Possible reorder
ApplyOptions = function(self, options) local oldPoolSize = table.getn(self.ChatLineInterfaces) local size = options.font_size or 14 + -- Set the seed row's font first so RebuildPool's height calc uses + -- the new metric, then resize the pool, then propagate the font + -- to the surviving rows. + if self.ChatLineInterfaces[1] then + self.ChatLineInterfaces[1]:SetFontSize(size) + end + self:RebuildPool() for _, line in ipairs(self.ChatLineInterfaces) do line:SetFontSize(size) end - -- Row height tracks the font, so the pool may need resizing; - -- wrap widths depend on font metrics, so rewrap all entries. - self:RebuildPool() self:RewrapAll()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatLinesInterface.lua` around lines 264 - 281, In ApplyOptions, avoid calling SetFontSize on lines that may be destroyed by RebuildPool: compute size as now, call self:RebuildPool() before iterating self.ChatLineInterfaces, then loop over the surviving entries and call line:SetFontSize(size) and then self:RewrapAll() (and keep the subsequent RefreshVirtualSize(), RecomputeScrollTopForPoolChange(oldPoolSize) and CalcVisible calls) so you only update font on controls that still exist; reference ApplyOptions, SetFontSize, RebuildPool, RewrapAll, RefreshVirtualSize, RecomputeScrollTopForPoolChange, and CalcVisible.
576-591: Duplicate bodies inOnResizeLive/OnResizeFinished.The two handlers are identical line-for-line. If the intent is for them to diverge later (e.g., skip rewrap during live resize), keep as-is; otherwise factor into a single helper to avoid drift.
♻️ Possible factor
+ ---@param self UIChatLinesInterface + ApplyResize = function(self) + local oldPoolSize = table.getn(self.ChatLineInterfaces) + self:RebuildPool() + self:RewrapAll() + self:RecomputeScrollTopForPoolChange(oldPoolSize) + self:CalcVisible() + end, + ---@param self UIChatLinesInterface - OnResizeLive = function(self) - local oldPoolSize = table.getn(self.ChatLineInterfaces) - self:RebuildPool() - self:RewrapAll() - self:RecomputeScrollTopForPoolChange(oldPoolSize) - self:CalcVisible() - end, + OnResizeLive = function(self) self:ApplyResize() end, ---@param self UIChatLinesInterface - OnResizeFinished = function(self) - local oldPoolSize = table.getn(self.ChatLineInterfaces) - self:RebuildPool() - self:RewrapAll() - self:RecomputeScrollTopForPoolChange(oldPoolSize) - self:CalcVisible() - end, + OnResizeFinished = function(self) self:ApplyResize() end,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatLinesInterface.lua` around lines 576 - 591, Both OnResizeLive and OnResizeFinished currently contain identical bodies (calling RebuildPool, RewrapAll, RecomputeScrollTopForPoolChange(oldPoolSize), and CalcVisible) which risks drift; factor this shared logic into a single helper (e.g., a method named ApplyResizeUpdate or DoResizeUpdate) that captures obtaining oldPoolSize via table.getn(self.ChatLineInterfaces) and then calls RebuildPool, RewrapAll, RecomputeScrollTopForPoolChange, and CalcVisible, then have OnResizeLive and OnResizeFinished each call that helper (or, if intended to differ later, keep one as a thin wrapper that calls the helper and adds extra behavior).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@lua/ChatUtils.lua`:
- Line 65: Update the `@param` annotation in ChatUtils.lua for the data parameter
to match actual usage: change Sender to a string and Msg to the ChatPayload
table type (instead of integer and string); reference the producing call site
ChatController.Send (where Sender = senderData and senderData.nickname or
tostring(focusArmy) and Msg = msg) to verify the payload shape and ensure the
annotation uses the correct type name for ChatPayload.
In `@lua/shared/ChatPayload.lua`:
- Line 33: The `@field` annotation for ChatPayload's From should be changed to
optional to match the class doc and runtime behavior: update the ChatPayload
declaration to mark From as optional (e.g., use `number?` or the
language-specific optional syntax for `@field`) so static analysis aligns with the
fact that Send() in ChatController does not set From and the sim relay
(RelayChatMessage) populates it later; ensure the annotation uses the same
optional style used elsewhere in this file for consistency.
In `@lua/SimUtils.lua`:
- Around line 1538-1549: The code computes capped amounts massGiven and
energyGiven but still calls toBrain:GiveResource with the uncapped massTaken and
energyTaken, causing the giver to lose overflowed resources; update the two
calls to toBrain:GiveResource to pass massGiven and energyGiven (respectively)
instead of massTaken and energyTaken so the actual transferred amounts match the
computed caps (refer to variables massCapacity, energyCapacity, massGiven,
energyGiven and the toBrain:GiveResource calls).
---
Nitpick comments:
In `@lua/ui/game/chat/ChatController.lua`:
- Around line 343-345: LOCF mutates msg.text in place which can corrupt
reprocessed messages; in the handler that calls LOCF (the OnReceive path that
currently sets msg.text = LOCF(msg.text, unpack(msg.Args))), avoid mutating the
incoming payload: either format into a local variable (e.g., local formatted =
LOCF(msg.text, unpack(msg.Args)) and use that for downstream display) or call
LOCF and then immediately clear msg.Args (msg.Args = nil) before writing back to
msg.text so subsequent passes won't re-format; update the code around LOCF usage
and ensure OnSyncChatMessages / OnReceive consumers use the non-mutated msg or
the cleared Args invariant.
- Around line 233-245: GetArmyData currently mutates the engine's armies table
by assigning v.ArmyID = i when looking up by nickname; instead avoid touching
the original table — either return a shallow copy of the army entry with ArmyID
set, or return the found entry and the index i as a second return value (e.g.,
return armyCopy, i) so callers can use the ID without altering GetArmiesTable()
data; update the function GetArmyData and any callers to handle the new return
shape accordingly.
In `@lua/ui/game/chat/ChatLinesInterface.lua`:
- Around line 264-281: In ApplyOptions, avoid calling SetFontSize on lines that
may be destroyed by RebuildPool: compute size as now, call self:RebuildPool()
before iterating self.ChatLineInterfaces, then loop over the surviving entries
and call line:SetFontSize(size) and then self:RewrapAll() (and keep the
subsequent RefreshVirtualSize(), RecomputeScrollTopForPoolChange(oldPoolSize)
and CalcVisible calls) so you only update font on controls that still exist;
reference ApplyOptions, SetFontSize, RebuildPool, RewrapAll, RefreshVirtualSize,
RecomputeScrollTopForPoolChange, and CalcVisible.
- Around line 576-591: Both OnResizeLive and OnResizeFinished currently contain
identical bodies (calling RebuildPool, RewrapAll,
RecomputeScrollTopForPoolChange(oldPoolSize), and CalcVisible) which risks
drift; factor this shared logic into a single helper (e.g., a method named
ApplyResizeUpdate or DoResizeUpdate) that captures obtaining oldPoolSize via
table.getn(self.ChatLineInterfaces) and then calls RebuildPool, RewrapAll,
RecomputeScrollTopForPoolChange, and CalcVisible, then have OnResizeLive and
OnResizeFinished each call that helper (or, if intended to differ later, keep
one as a thin wrapper that calls the helper and adds extra behavior).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 27ce0f1f-395c-41b4-99a8-452bc5b9d587
📒 Files selected for processing (9)
.claude/.gitignorelua/ChatUtils.lualua/SimUtils.lualua/shared/ChatPayload.lualua/ui/game/chat/ChatController.lualua/ui/game/chat/ChatEditInterface.lualua/ui/game/chat/ChatLinesInterface.lualua/ui/game/chat/ChatModel.lualua/ui/game/chat/ChatUtils.lua
✅ Files skipped from review due to trivial changes (1)
- .claude/.gitignore
| --- `Sync.ChatMessages` (see `SendChatMessage`) and external replay parsers | ||
| --- read the `Sender`/`Msg` fields off the recorded `GiveResourcesToPlayer` | ||
| --- callback args, which the UI sender emits once per outgoing message. | ||
| ---@param data {Sender: integer, Msg: string} |
There was a problem hiding this comment.
Annotation mismatch: Sender is a string and Msg is a ChatPayload, not the documented types.
Looking at the producing call site in ChatController.Send (lines 533–534): Sender = senderData and senderData.nickname or tostring(focusArmy) is a string and Msg = msg is the ChatPayload table. The @param data {Sender: integer, Msg: string} annotation is reversed/wrong on both fields.
📝 Proposed fix
----@param data {Sender: integer, Msg: string}
+---@param data {Sender: string, Msg: ChatPayload}
function SendChatToReplay(data)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lua/ChatUtils.lua` at line 65, Update the `@param` annotation in ChatUtils.lua
for the data parameter to match actual usage: change Sender to a string and Msg
to the ChatPayload table type (instead of integer and string); reference the
producing call site ChatController.Send (where Sender = senderData and
senderData.nickname or tostring(focusArmy) and Msg = msg) to verify the payload
shape and ensure the annotation uses the correct type name for ChatPayload.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
lua/ui/game/chat/commands/ChatCommandTypes.lua (1)
58-61:Resolvers.Playerdoc claim doesn't match behavior.The
--- ... Rejects "all"/"allies".comment suggests an explicit rejection, butResolveArmyonly rejects them implicitly via the "no player named 'all'." fallthrough. For/whisper allthe user gets a misleading "no player named 'all'" error instead of something like "use a player nickname or army ID, not 'all'/'allies'". Optional refinement:♻️ Proposed clearer rejection
Resolvers.Player = function(token) + local lower = string.lower(token) + if lower == 'all' or lower == 'allies' or lower == 'team' then + return false, string.format("'%s' is a recipient, not a player.", token) + end return ResolveArmy(token) end🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/commands/ChatCommandTypes.lua` around lines 58 - 61, The comment says the doc implies explicit rejection of "all"/"allies" but Resolvers.Player currently just calls ResolveArmy and returns a misleading "no player named 'all'" error; update Resolvers.Player to first check if token equals "all" or "allies" (case-insensitive) and return a clear rejection/error message like "use a player nickname or army ID, not 'all'/'allies'"; otherwise call ResolveArmy(token) as before so normal resolution remains unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@lua/ui/game/chat/commands/builtin/DebugDumpControls.lua`:
- Around line 5-6: The Description string for the builtin command Name =
'debug-dump-controls' is incorrect (a copy/paste leftover referencing the
Alt+click army-switch toggle); update the Description to accurately describe
what Execute does (calling UI_DumpControlsUnderCursor) so help/autocomplete
shows the correct behavior — locate the Description field in
DebugDumpControls.lua and replace the misleading text with a concise description
like "Dump UI controls under the cursor" or similar.
---
Nitpick comments:
In `@lua/ui/game/chat/commands/ChatCommandTypes.lua`:
- Around line 58-61: The comment says the doc implies explicit rejection of
"all"/"allies" but Resolvers.Player currently just calls ResolveArmy and returns
a misleading "no player named 'all'" error; update Resolvers.Player to first
check if token equals "all" or "allies" (case-insensitive) and return a clear
rejection/error message like "use a player nickname or army ID, not
'all'/'allies'"; otherwise call ResolveArmy(token) as before so normal
resolution remains unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 38ea1374-7118-47a4-834e-d20213cf8155
📒 Files selected for processing (26)
lua/ui/game/chat/commands/ChatCommandRegistry.lualua/ui/game/chat/commands/ChatCommandTypes.lualua/ui/game/chat/commands/builtin/All.lualua/ui/game/chat/commands/builtin/Allies.lualua/ui/game/chat/commands/builtin/Clear.lualua/ui/game/chat/commands/builtin/DebugDumpControls.lualua/ui/game/chat/commands/builtin/DebugLog.lualua/ui/game/chat/commands/builtin/DebugStatistics.lualua/ui/game/chat/commands/builtin/Debugger.lualua/ui/game/chat/commands/builtin/EndMission.lualua/ui/game/chat/commands/builtin/GiftResources.lualua/ui/game/chat/commands/builtin/GiftUnits.lualua/ui/game/chat/commands/builtin/Help.lualua/ui/game/chat/commands/builtin/Load.lualua/ui/game/chat/commands/builtin/Mute.lualua/ui/game/chat/commands/builtin/Pause.lualua/ui/game/chat/commands/builtin/Recall.lualua/ui/game/chat/commands/builtin/Restart.lualua/ui/game/chat/commands/builtin/Resume.lualua/ui/game/chat/commands/builtin/Save.lualua/ui/game/chat/commands/builtin/Speed.lualua/ui/game/chat/commands/builtin/Taunt.lualua/ui/game/chat/commands/builtin/ToEngineers.lualua/ui/game/chat/commands/builtin/ToTick.lualua/ui/game/chat/commands/builtin/Unmute.lualua/ui/game/chat/commands/builtin/Whisper.lua
✅ Files skipped from review due to trivial changes (1)
- lua/ui/game/chat/commands/builtin/Resume.lua
🚧 Files skipped from review as they are similar to previous changes (15)
- lua/ui/game/chat/commands/builtin/Pause.lua
- lua/ui/game/chat/commands/builtin/Restart.lua
- lua/ui/game/chat/commands/builtin/Allies.lua
- lua/ui/game/chat/commands/builtin/EndMission.lua
- lua/ui/game/chat/commands/builtin/DebugStatistics.lua
- lua/ui/game/chat/commands/builtin/Recall.lua
- lua/ui/game/chat/commands/builtin/Speed.lua
- lua/ui/game/chat/commands/builtin/Unmute.lua
- lua/ui/game/chat/commands/builtin/Load.lua
- lua/ui/game/chat/commands/builtin/Debugger.lua
- lua/ui/game/chat/commands/builtin/DebugLog.lua
- lua/ui/game/chat/commands/builtin/Help.lua
- lua/ui/game/chat/commands/builtin/ToTick.lua
- lua/ui/game/chat/commands/builtin/GiftUnits.lua
- lua/ui/game/chat/commands/builtin/Taunt.lua
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (4)
lua/ui/game/chat/ChatLineInterface.lua (1)
94-95: ReuseDefaultBodyColorinstead of duplicating the literal.
'ffc2f6ff'here is the same magic value as theDefaultBodyColorconstant declared at Line 17. The init color is overwritten bySetHeader/SetContinuation/Clearon first use, but keeping a duplicate literal is a quiet drift hazard.♻️ Proposed fix
self.Text = UIUtil.CreateText(self, '', 14, 'Arial') - self.Text:SetColor('ffc2f6ff') + self.Text:SetColor(DefaultBodyColor) self.Text:SetDropShadow(true)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatLineInterface.lua` around lines 94 - 95, Replace the duplicated literal color with the existing constant: in the ChatLineInterface initializer where self.Text is created (UIUtil.CreateText(...)) and its color set via self.Text:SetColor('ffc2f6ff'), use the predefined DefaultBodyColor constant instead; update the call to SetColor to reference DefaultBodyColor so the class (ChatLineInterface) consistently reuses the same color value that SetHeader/SetContinuation/Clear expect.lua/ui/game/chat/ChatUtils.lua (1)
53-65: Pre-scale the literal+ 4gap to keep wrap consistent across UI scales.
measureLine.Right(),Name.Left(), andName:GetStringAdvance(name)are already in scaled pixels, so adding an unscaled4shrinks the reserved gap proportionally asui_scalegrows. Capture a scaled constant once and reuse it.♻️ Proposed fix
function WrapEntry(entry, measureLine) if not measureLine then entry.WrappedText = { entry.Text or '' } return end local name = entry.Name or '' + local gap = LayoutHelpers.ScaleNumber(4) local lines = MauiWrapText(entry.Text or '', function(lineIndex) if lineIndex == 1 then return measureLine.Right() - - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + 4) + - (measureLine.Name.Left() + measureLine.Name:GetStringAdvance(name) + gap) else return measureLine.Right() - - (measureLine.Name.Left() + 4) + - (measureLine.Name.Left() + gap) end end,This requires importing
LayoutHelpersat the top:local MauiWrapText = import("/lua/maui/text.lua").WrapText local ChatPayload = import("/lua/shared/ChatPayload.lua") +local LayoutHelpers = import("/lua/maui/layouthelpers.lua")🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatUtils.lua` around lines 53 - 65, The literal "+ 4" must be scaled with the UI scale and reused: import LayoutHelpers (or ensure it's available), compute a local scaled gap (e.g., local gap = LayoutHelpers.ScaleNumber(4)) and replace both occurrences of " + 4" inside the MauiWrapText width function with that scaled gap so measureLine.Right(), measureLine.Name.Left(), and measureLine.Name:GetStringAdvance(name) remain in scaled pixels and the reserved gap stays consistent across ui_scale; keep the new local variable near where measureLine is used so MauiWrapText's width callbacks reference gap instead of the unscaled 4.lua/ui/game/chat/ChatLinesInterface.lua (1)
238-241: RedundantRefreshVirtualSizecall inApplyOptions.
self:RewrapAll()at line 239 already invokesself:RefreshVirtualSize(history)(line 264), so the explicit call at line 241 walks the entire history a second time on every committed-options change. Drop the duplicate to keep the option-change path single-pass.♻️ Proposed cleanup
self:RebuildPool() self:RewrapAll() - self:RefreshVirtualSize() self:RecomputeScrollTopForPoolChange(oldPoolSize) self:CalcVisible()As per coding guidelines: "WrappedText cache on UIChatEntry should be updated by ChatLinesInterface when entry text or row width changes" —
RewrapAllis already the canonical update path and refreshes virtual size as part of that cycle.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatLinesInterface.lua` around lines 238 - 241, Remove the redundant call to RefreshVirtualSize from ApplyOptions: after calling self:RebuildPool() and self:RewrapAll() the virtual size is already recomputed by RewrapAll (which calls self:RefreshVirtualSize(history)), so delete the explicit self:RefreshVirtualSize() invocation in ApplyOptions to avoid a second full-history pass; keep RebuildPool and RewrapAll as-is so wrapped-text caching and virtual size updates remain driven by RewrapAll.lua/ui/game/chat/ChatController.lua (1)
283-285: WrapLOCFwithpcallto handle mismatched Args gracefully.
ChatPayload.IsValidPayloadvalidates thatArgsis a table (if present) but does not verify its contents match the format specifiers inmsg.text. A malformed payload like{ text='%d', Args={'oops'} }will causestring.formatto fail. Usepcallto catch the error and fall back to the raw template:Hardening
if msg.Args then - msg.text = LOCF(msg.text, unpack(msg.Args)) + local ok, formatted = pcall(LOCF, msg.text, unpack(msg.Args)) + if ok then + msg.text = formatted + end end🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/ui/game/chat/ChatController.lua` around lines 283 - 285, Wrap the LOCF(msg.text, unpack(msg.Args)) call in a pcall to prevent a malformed Args table from causing an error; in ChatController.lua where you currently have the LOCF invocation (handling msg.Args), call pcall(LOCF, msg.text, unpack(msg.Args)) and only replace msg.text when pcall returns true, otherwise leave msg.text as the original template and optionally log or ignore the format failure; this change hardens handling around msg.Args and works with existing ChatPayload.IsValidPayload validations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@lua/ui/game/chat/ChatController.lua`:
- Around line 268-328: OnReceive currently misses id-based deduping promised by
the docstring; add an early check to skip messages whose msg.Id is already in
the chat history and ensure messages processed here seed the history after
rendering. Concretely: in OnReceive, before any rendering/LOCF/Notify logic
consult ChatModel.history (or the module's history table used by
OnSyncChatMessages) and if msg.Id and history[msg.Id] then return; after
AppendChatLine succeeds, if msg.Id then set history[msg.Id] = true so future
OnReceive/OnSyncChatMessages calls will be deduped. Reference: OnReceive,
msg.Id, AppendChatLine, OnSyncChatMessages, ChatModel.history (history table).
In `@lua/ui/game/chat/ChatListInterface.lua`:
- Around line 62-72: The global click handler onOutsideClick currently calls
self:Destroy() unconditionally via UIMain.AddOnMouseClickedFunc, which preempts
control-level handlers; modify onOutsideClick to accept the event parameter and
only call self:Destroy() when the click is outside this popup's bounds by
checking event.x/event.y against this control's Left()/Right()/Top()/Bottom();
apply the same bounds-guarded pattern to the other onOutsideClick instance
around the 126–136 range and ensure UIMain.RemoveOnMouseClickedFunc is still
called in self.OnDestroy and _OnClosed behavior remains unchanged.
- Around line 162-174: The popup layout is double-scaling dimensions because
__post_init computes maxWidth/totalHeight from already-scaled entry.Text: do not
pass those raw numbers into Layouter:Width()/Height() which auto-scale; instead
call Layouter:SetFunction("Width", function() return maxWidth + 40 end) and
Layouter:SetFunction("Height", function() return totalHeight end) (or otherwise
use :SetFunction for the same keys) so the values bypass Layouter's internal
ScaleNumber; update the __post_init block around the
Layouter(self):Width(...):Height(...):End() call to use :SetFunction for "Width"
and "Height" referencing maxWidth and totalHeight computed from
entry.Text.Width()/Height().
In `@lua/ui/game/chat/config/ChatConfigController.lua`:
- Around line 69-80: SetMutedLive currently mutates only Committed (via Model()
and model.Committed:Set) causing Pending to remain stale and later Apply to
overwrite the live change; update SetMutedLive to mirror the same mutated
options into Pending as well (i.e., after computing options.muted and calling
model.Committed:Set(options), also update model.Pending:Set(...) or the
equivalent Pending API so the dialog’s Pending state and the PendingObserver in
ChatConfigInterface.lua remain in sync with the live mute change).
In `@lua/ui/game/chat/config/ChatConfigInterface.lua`:
- Around line 418-420: The OnClose handler currently calls Close() only, leaving
Pending edits intact; change the OnClose closure to call Cancel() followed by
Close() (i.e., mirror the explicit Cancel button behavior) so X discards drafts.
Additionally, modify Open (the function that currently calls Show() on the
existing instance) to re-sync Pending from Committed before calling Show() —
ensure Open resets the dialog's Pending state to the current Committed values
(e.g., copy/mirror Committed -> Pending or call the existing reset logic) so
reopening always reflects up-to-date committed settings and avoids clobbering
intervening SetMutedLive changes.
- Around line 39-51: The UI strings in ColorDefs and CheckboxDefs (e.g., Text
values "All","Allies","Private","Links","Notify" and "Default recipient:
allies","Show feed background","Show camera links") are hardcoded English; wrap
every user-visible Text/Tooltip and button/label strings referenced by
ChatConfigInterface (including slider preview labels generated with
string.format) with the localization token pattern <LOC ...>...LOC so they go
through the localization pipeline, and replace any string.format("Font Size:
%d", value) style formatting with a localized format token (e.g., a <LOC> token
that includes a placeholder) so the numeric value is interpolated via the
localization-aware formatting; update the entries in ColorDefs and CheckboxDefs
and the slider preview/label code paths accordingly (use the same unique
keys/identifiers currently used in the diff so callers like the slider preview
and any UI builders pick up the localized strings).
---
Nitpick comments:
In `@lua/ui/game/chat/ChatController.lua`:
- Around line 283-285: Wrap the LOCF(msg.text, unpack(msg.Args)) call in a pcall
to prevent a malformed Args table from causing an error; in ChatController.lua
where you currently have the LOCF invocation (handling msg.Args), call
pcall(LOCF, msg.text, unpack(msg.Args)) and only replace msg.text when pcall
returns true, otherwise leave msg.text as the original template and optionally
log or ignore the format failure; this change hardens handling around msg.Args
and works with existing ChatPayload.IsValidPayload validations.
In `@lua/ui/game/chat/ChatLineInterface.lua`:
- Around line 94-95: Replace the duplicated literal color with the existing
constant: in the ChatLineInterface initializer where self.Text is created
(UIUtil.CreateText(...)) and its color set via self.Text:SetColor('ffc2f6ff'),
use the predefined DefaultBodyColor constant instead; update the call to
SetColor to reference DefaultBodyColor so the class (ChatLineInterface)
consistently reuses the same color value that SetHeader/SetContinuation/Clear
expect.
In `@lua/ui/game/chat/ChatLinesInterface.lua`:
- Around line 238-241: Remove the redundant call to RefreshVirtualSize from
ApplyOptions: after calling self:RebuildPool() and self:RewrapAll() the virtual
size is already recomputed by RewrapAll (which calls
self:RefreshVirtualSize(history)), so delete the explicit
self:RefreshVirtualSize() invocation in ApplyOptions to avoid a second
full-history pass; keep RebuildPool and RewrapAll as-is so wrapped-text caching
and virtual size updates remain driven by RewrapAll.
In `@lua/ui/game/chat/ChatUtils.lua`:
- Around line 53-65: The literal "+ 4" must be scaled with the UI scale and
reused: import LayoutHelpers (or ensure it's available), compute a local scaled
gap (e.g., local gap = LayoutHelpers.ScaleNumber(4)) and replace both
occurrences of " + 4" inside the MauiWrapText width function with that scaled
gap so measureLine.Right(), measureLine.Name.Left(), and
measureLine.Name:GetStringAdvance(name) remain in scaled pixels and the reserved
gap stays consistent across ui_scale; keep the new local variable near where
measureLine is used so MauiWrapText's width callbacks reference gap instead of
the unscaled 4.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fcffe53d-b331-4bf0-90bf-4d70221471c6
📒 Files selected for processing (17)
lua/ui/controls/floattext.lualua/ui/game/chat/ChatCommandHintInterface.lualua/ui/game/chat/ChatCompletion.lualua/ui/game/chat/ChatController.lualua/ui/game/chat/ChatDebug.lualua/ui/game/chat/ChatEditInterface.lualua/ui/game/chat/ChatFactionBadge.lualua/ui/game/chat/ChatFeedInterface.lualua/ui/game/chat/ChatInterface.lualua/ui/game/chat/ChatLineInterface.lualua/ui/game/chat/ChatLinesInterface.lualua/ui/game/chat/ChatListInterface.lualua/ui/game/chat/ChatModel.lualua/ui/game/chat/ChatUtils.lualua/ui/game/chat/config/ChatConfigController.lualua/ui/game/chat/config/ChatConfigInterface.lualua/ui/game/chat/config/ChatConfigModel.lua
✅ Files skipped from review due to trivial changes (3)
- lua/ui/game/chat/ChatFactionBadge.lua
- lua/ui/game/chat/ChatDebug.lua
- lua/ui/game/chat/ChatEditInterface.lua
🚧 Files skipped from review as they are similar to previous changes (2)
- lua/ui/game/chat/ChatCompletion.lua
- lua/ui/game/chat/config/ChatConfigModel.lua
| function OnReceive(sender, msg) | ||
| if type(sender) ~= 'string' or sender == '' then | ||
| sender = 'nil sender' | ||
| end | ||
|
|
||
| -- Shape validation lives in `ChatPayload.IsValidPayload` (shared with the | ||
| -- sim relay). The receive path is reachable from external mods, so the | ||
| -- payload can't be trusted. Sender / observer consistency below needs | ||
| -- session context the shared validator can't see. | ||
| if not ChatPayload.IsValidPayload(msg) then return end | ||
|
|
||
| -- LOCF-style format-on-receive: when the sender ships `Args`, treat | ||
| -- `msg.text` as a `string.format` template (typically a `<LOC ...>` tag) | ||
| -- so the result respects the viewer's locale. The cap was applied to the | ||
| -- pre-format template, so `LOCF` can legitimately exceed it. | ||
| if msg.Args then | ||
| msg.text = LOCF(msg.text, unpack(msg.Args)) | ||
| end | ||
|
|
||
| -- Notify owns the display decision for `to='notify'`; only fall through | ||
| -- to rendering a chat line if it returns false. | ||
| if msg.to == ChatModel.RecipientNotify and not import("/lua/ui/notify/notify.lua").processIncomingMessage(sender, msg) then | ||
| return | ||
| end | ||
|
|
||
| local armyData = GetArmyData(sender) | ||
| if not armyData and GetFocusArmy() ~= -1 and not SessionIsReplay() then | ||
| return | ||
| end | ||
|
|
||
| -- `msg.Observer` is only set when the sender has no army entry. A peer | ||
| -- claiming Observer while resolving to a real army is malformed — drop | ||
| -- the message entirely rather than stripping the flag, which would let | ||
| -- manipulated traffic render under a different label. | ||
| if msg.Observer and armyData then return end | ||
|
|
||
| local to = msg.to | ||
| local descriptor = ToStrings[to] or ToStrings.private | ||
| local towho = msg.Observer and LOC("<LOC lobui_0692>to observers:") or LOC(descriptor.text) | ||
|
|
||
| local name | ||
| if type(to) == 'number' and SessionIsReplay() then | ||
| -- In a replay, private messages need the full routing so spectators | ||
| -- can attribute the conversation. | ||
| name = string.format("%s %s %s:", sender, LOC(ToStrings.to.text), | ||
| (GetArmyData(to) or {}).nickname or tostring(to)) | ||
| else | ||
| name = sender .. ' ' .. towho | ||
| end | ||
|
|
||
| AppendChatLine { | ||
| Name = name, | ||
| Text = msg.text, | ||
| ArmyData = armyData, | ||
| IsObserver = msg.Observer, | ||
| Recipient = to, | ||
| Camera = msg.camera, | ||
| Location = msg.location, | ||
| Id = msg.Id, | ||
| } | ||
| end |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm OnReceive is the only sink for both delivery paths and that no
# other dedup layer exists upstream.
rg -nP --type=lua -C2 'RegisterChatFunc\s*\('
rg -nP --type=lua -C2 'AddOnSyncHashedCallback\s*\(\s*OnSyncChatMessages'
rg -nP --type=lua -C3 '\bOnReceive\s*\(' lua/ui/game/chat/Repository: FAForever/fa
Length of output: 5300
🏁 Script executed:
# View docstring at lines 330-335 and Send function to confirm the claim
sed -n '330,335p' lua/ui/game/chat/ChatController.lua
echo "---"
sed -n '387,410p' lua/ui/game/chat/ChatController.luaRepository: FAForever/fa
Length of output: 1272
🏁 Script executed:
# Continue reading Send function to find both delivery paths (SessionSendChatMessage and OnEcho calls)
sed -n '387,473p' lua/ui/game/chat/ChatController.luaRepository: FAForever/fa
Length of output: 3357
OnReceive lacks the symmetric id-dedup the docstring promises.
OnSyncChatMessages (line 347) skips messages whose msg.Id is already in history, but OnReceive itself does no id check. In live play both delivery paths fire unconditionally: SessionSendChatMessage (direct via registered 'Chat' handler) and SimCallback SendChatMessage (sim relay). The engine does not guarantee ordering between them. When the sim path lands first, OnSyncChatMessages → OnReceive renders the entry; the subsequent direct SessionSendChatMessage → OnReceive then renders the same message again because OnReceive never consults history. The docstring at lines 330–335 explicitly states "whichever path lands first seeds the entry's Id and this handler skips duplicates by id" — making this an intent/impl mismatch.
🛡️ Proposed fix: dedup at the OnReceive entry point
function OnReceive(sender, msg)
if type(sender) ~= 'string' or sender == '' then
sender = 'nil sender'
end
if not ChatPayload.IsValidPayload(msg) then return end
+ -- Symmetric dedupe with OnSyncChatMessages: whichever path lands first
+ -- seeds history with msg.Id; the other path then short-circuits here.
+ if msg.Id then
+ local history = ChatModel.GetSingleton().History()
+ for _, entry in history do
+ if entry.Id == msg.Id then return end
+ end
+ end
+
if msg.Args then
msg.text = LOCF(msg.text, unpack(msg.Args))
end🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lua/ui/game/chat/ChatController.lua` around lines 268 - 328, OnReceive
currently misses id-based deduping promised by the docstring; add an early check
to skip messages whose msg.Id is already in the chat history and ensure messages
processed here seed the history after rendering. Concretely: in OnReceive,
before any rendering/LOCF/Notify logic consult ChatModel.history (or the
module's history table used by OnSyncChatMessages) and if msg.Id and
history[msg.Id] then return; after AppendChatLine succeeds, if msg.Id then set
history[msg.Id] = true so future OnReceive/OnSyncChatMessages calls will be
deduped. Reference: OnReceive, msg.Id, AppendChatLine, OnSyncChatMessages,
ChatModel.history (history table).
| local ColorDefs = { | ||
| { Key = ChatConfigModel.KeyAllColor, Text = "All", Tooltip = 'chat_color' }, | ||
| { Key = ChatConfigModel.KeyAlliesColor, Text = "Allies", Tooltip = 'chat_color' }, | ||
| { Key = ChatConfigModel.KeyPrivColor, Text = "Private", Tooltip = 'chat_color' }, | ||
| { Key = ChatConfigModel.KeyLinkColor, Text = "Links", Tooltip = 'chat_color' }, | ||
| { Key = ChatConfigModel.KeyNotifyColor, Text = "Notify", Tooltip = 'chat_color' }, | ||
| } | ||
|
|
||
| local CheckboxDefs = { | ||
| { Key = ChatConfigModel.KeySendType, Text = "Default recipient: allies", Tooltip = 'chat_send_type' }, | ||
| { Key = ChatConfigModel.KeyFeedBackground, Text = "Show feed background", Tooltip = 'chat_feed_background' }, | ||
| { Key = ChatConfigModel.KeyLinks, Text = "Show camera links", Tooltip = 'chat_filter' }, | ||
| } |
There was a problem hiding this comment.
Hardcoded English UI strings — inconsistent with the PR's localization story.
The PR objectives call out localized system/AI/event messages, but every label/title/button text in this dialog is a raw English string ("Chat Configuration", "Message Colors", "All", "Allies", "Font Size: 14", "Apply", "OK", "Cancel", etc.). Wrap user-visible strings with <LOC ...>/LOC so they go through the localization pipeline like the rest of the UI.
Also worth noting: the slider preview labels use string.format("Font Size: %d", value) etc., which won't survive translation either — the formatted prefix needs to come from a <LOC> token with the value interpolated.
Also applies to: 97-99, 106-106, 133-133, 148-148, 163-163, 179-179, 195-195, 216-216, 222-222, 225-225, 231-231
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@lua/ui/game/chat/config/ChatConfigInterface.lua` around lines 39 - 51, The UI
strings in ColorDefs and CheckboxDefs (e.g., Text values
"All","Allies","Private","Links","Notify" and "Default recipient: allies","Show
feed background","Show camera links") are hardcoded English; wrap every
user-visible Text/Tooltip and button/label strings referenced by
ChatConfigInterface (including slider preview labels generated with
string.format) with the localization token pattern <LOC ...>...LOC so they go
through the localization pipeline, and replace any string.format("Font Size:
%d", value) style formatting with a localized format token (e.g., a <LOC> token
that includes a placeholder) so the numeric value is interpolated via the
localization-aware formatting; update the entries in ColorDefs and CheckboxDefs
and the slider preview/label code paths accordingly (use the same unique
keys/identifiers currently used in the diff so callers like the slider preview
and any UI builders pick up the localized strings).
There was a problem hiding this comment.
🧹 Nitpick comments (1)
lua/aibrains/components/ChatBrainComponent.lua (1)
101-115: Consider validating the payload before relaying.
SendChatToonly checks thattextis a non-empty string before callingChatUtils.RelayChatMessage, which (unlike the UI-originatedChatUtils.SendChatMessage) does not runChatPayload.IsValidPayload. As a result, an AI/campaign caller can leak a payload intoSync.ChatMessagesthat the shared validator was designed to reject:
textlonger thanChatPayload.MaxMessageLength(200 UTF‑8 chars) — silently passes.toother than'all' | 'allies' | integer(e.g.,'notify', a typo, or a stale recipient table) — silently dropped byIsLocalRecipient, with no feedback to the caller.argspassed as a non-table, orlocationas a non-table — these would normally be rejected byIsValidPayload, but here they reach the UI'sLOCF/camera-link handlers and can crash on click.Since
RelayChatMessageis documented as the shared sim-side entry point with policy enforced "in exactly one place", runningChatPayload.IsValidPayload(msg)here (or moving the check intoRelayChatMessage) would close the gap and give AI authors a deterministic failure mode rather than a silently dropped or malformed line.♻️ Suggested change
+local ChatPayload = import("/lua/shared/ChatPayload.lua") + ... SendChatTo = function(self, to, text, args, location) if type(text) ~= 'string' or text == '' then return end local msg = { Chat = true, to = to, text = text, Args = args, From = self:GetArmyIndex(), location = location, } msg.Id = tostring(msg) + if not ChatPayload.IsValidPayload(msg) then + WARN(string.format("AIChatBrainComponent: dropped malformed chat from army %s (to=%s, len=%d)", + tostring(msg.From), tostring(to), STR_Utf8Len(text))) + return + end + ChatUtils.RelayChatMessage(msg) end,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@lua/aibrains/components/ChatBrainComponent.lua` around lines 101 - 115, SendChatTo currently only checks that text is a non-empty string and then calls ChatUtils.RelayChatMessage, which lets invalid chat payloads reach Sync.ChatMessages; call ChatPayload.IsValidPayload(msg) inside SendChatTo (or move the check into ChatUtils.RelayChatMessage) before relaying, and if it returns false reject the message (log a descriptive error including ChatPayload.MaxMessageLength and the offending fields like to/Args/location) so malformed payloads (bad recipient values that IsLocalRecipient would drop, non-table Args/location, or overly long text) are caught and not sent to RelayChatMessage/LOCF.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@lua/aibrains/components/ChatBrainComponent.lua`:
- Around line 101-115: SendChatTo currently only checks that text is a non-empty
string and then calls ChatUtils.RelayChatMessage, which lets invalid chat
payloads reach Sync.ChatMessages; call ChatPayload.IsValidPayload(msg) inside
SendChatTo (or move the check into ChatUtils.RelayChatMessage) before relaying,
and if it returns false reject the message (log a descriptive error including
ChatPayload.MaxMessageLength and the offending fields like to/Args/location) so
malformed payloads (bad recipient values that IsLocalRecipient would drop,
non-table Args/location, or overly long text) are caught and not sent to
RelayChatMessage/LOCF.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 632b0887-8395-45c0-b167-ebbd8a39aaba
📒 Files selected for processing (3)
lua/ChatUtils.lualua/aibrain.lualua/aibrains/components/ChatBrainComponent.lua
🚧 Files skipped from review as they are similar to previous changes (1)
- lua/aibrain.lua
|
@BlackYps I mentioned in the pull request description that this is primarily an experiment with Claude, but that the output in my opinion is a decent improvement on the status quo. The experiment is done on my end. I won't process further feedback until the game team is sure whether they want this alternative implementation of the chat window. Beyond perhaps excessive comments, I think in general this setup is much more maintainable then the current spaghetti code. But the choice is all yours. Please let me know what the game team wants in the upcoming weeks. If the answer is no, then feel free to close it. I won't process any additional feedback until the game team decided to want to proceed. |
Description of the proposed changes
See #7098 (comment) for some visuals
I was playing a game recently and someone shared me some units. I could not find them. Then I thought: let's add a chat message with some camera information in it in order to find them easier next time. That turned out to be quite hard in the original implementation, I could not figure it out.
On work I've been using Claude quite a bit. It is quite powerful. But what better to test its capabilities then on a code base, with a framework that the LLM did not train on indefinitely? One thing led to another, and here we have an entirely new chat window.
This chat window is implemented using the MVC-principle, to keep the model, the controller and the interface cleanly separated. I've learned from my last attempts with the autolobby, this time no property drilling but proper use of LazyVars.
In general all functionality of the existing chat window also exists in the new chat window. There are a few subtle differences, but none that are breaking in my point of view:
SetLayoutrotated throughbottom/left/rightHUD layouts; the new chat lets the user drag and resize freely instead.LastActivityLazyVar; the window'sOnFramechecksnow − LastActivityagainstfade_time. Net effect: a burst of incoming chat keeps the window open instead of auto-closing mid-conversation. Unchecking the pin also re-stamps activity, so you get a fullfade_timewindow instead of being closed instantly.In comparison, we receive a lot of new features and capabilities:
ui_scaledoesn't end up with a half-off-screen chat window.frame(0)when opened, so that you can always view the chat even after changing resolution./, these are simple to extend for UI mods. Built-ins include/all,/allies,/whisper(/w//pm),/help,/gift-resources,/gift-units,/recall,/mute,/unmute,/clear,/restart,/save,/load,/pause,/resume,/speed,/end-mission,/to-engineers,/to-tick,/taunt, plus a few/debug-…helpers./, you get a few suggestions of available commands in a popup above the edit box. When you use@(or just press Tab on a partial nickname) you can auto-complete player names. Tab cycles through candidates; commands like/whisper @Jipaccept the@-prefixed form too./mute @Nickand/unmute @Nickcommands.Locationhint (a world point or rectangle) instead of a full camera snapshot — useful for AI brains that don't have a saved view to ship.UI_Lua import(…).Toggle(). Useful for debugging in isolation, and the keybinding UI now exposes them under thechatcategory.We fix some bugs:
GiftResourcesToPlayercallback. The old sim callback is still emitted, but now once per message instead of X − 1 per player per message.And of course, what we started with:
Checklist
Check compatibility with replay parsing.
This should be untouched, but then again - one can't know for sure. Better be safe than sorry here.
Check whether the way AIs use the chat still works.
I did some basic testing using the new methods, called from the ai brain this works:
Testing done on the proposed changes
Tested locally with multiple players using the launch script inside the
scriptsfolder. Messages can be send just fine.Debug key actions
All actions are filed under the
chatcategory in the keybindings dialog (so they group with regular chat actions) but ship without default keys — bind them via the keybindings UI or invoke from the console withUI_Lua import("/lua/ui/game/chat/ChatDebug.lua").Foo().debug_chat_windowdebug_chat_configdebug_chat_append_system_messageSystem:line — exercisesAppendLocalSystemMessageand the system colour.debug_chat_append_short_messagedebug_chat_append_long_messageWrapEntryand the continuation-row layout at every supported font size.debug_chat_append_burstdebug_chat_append_camera_messageLocationhint at the current camera focus — exercises the cam-icon toggle on the row andCamera:MoveToon click (press, pan away, click the icon — camera bounces back).debug_chat_set_recipient_allall— exercises the recipient-label LazyVar binding.debug_chat_set_recipient_alliesallies.debug_chat_clear_historyCalcVisibleand model-side dirty propagation.Additional context
@BlackYps I don't think it makes sense to review this pull request line by line. Instead, it's a question of whether we want this as a whole or not. Yes, it will break a few things. Yes, it won't be perfect immediately. But, in my humble opinion, it is an improvement that makes the chat window maintainable again for the foreseeable future.
To be honest: I would not mind if this gets closed. The goal was to experiment with Claude and spec driven development on a code base that it did not quite train as much on as regular programming languages and/or frameworks. But it would be a loss in my opinion 😃 !
See #7098 (comment) for some visuals
Checklist
Summary by CodeRabbit
/all,/allies,/whisper,/mute,/unmute,/gift-units,/gift-resources,/save,/load,/pause,/resume,/recall,/help, and more.