Skip to content

RG-T117 Chat and Chatbot Entrypoints - #122

Open
ucswift wants to merge 3 commits into
masterfrom
develop
Open

RG-T117 Chat and Chatbot Entrypoints#122
ucswift wants to merge 3 commits into
masterfrom
develop

Conversation

@ucswift

@ucswift ucswift commented Aug 1, 2026

Copy link
Copy Markdown
Member

Description

This PR introduces Chat and Chatbot (Assistant) functionality to the Resgrid Dispatch app, providing real-time messaging capabilities including direct messages, group channels, incident channels, and an AI assistant.

Key Features Added

Chat System:

  • Full-featured chat with channels organized by type (direct messages, group channels, incident channels, and assistant)
  • Real-time messaging via a dedicated SignalR chat hub with presence, typing indicators, and delivery receipts
  • Rich message types: text (with link detection), images, GIFs, location sharing, and system messages
  • Message interactions: reactions, threaded replies, edit, delete, pin, flag for moderation, and copy
  • Urgent priority messages with acknowledgment banners
  • Optimistic message sending with a persisted outbox for offline reliability and automatic retry
  • New conversation creation (direct messages and ad-hoc groups) with recipient search
  • Attachment upload and rendering with auth-protected image sources
  • Unread badge counts, mark-as-read tracking, and notification preferences
  • Push notification deep-linking directly into chat conversations

Chatbot Assistant:

  • Dedicated assistant screen with a distinct UI theme
  • Async chatbot messaging with typing/thinking indicator
  • Session reset capability

Infrastructure:

  • New CHAT_HUB_NAME environment configuration
  • Chat hub integrated into the SignalR connection lifecycle (connect on app foreground, disconnect on background)
  • Sidebar navigation entries for Chat and Assistant
  • Comprehensive v4 Chat API model definitions (enums, inputs, responses, events, outbox)
  • Generalized the SignalR invoke method to support variadic arguments

Summary by CodeRabbit

  • New Features
    • Added real-time chat with channels, direct and group conversations, unread indicators, presence, typing status, reactions, threads, pins, attachments, GIFs, locations, and message search.
    • Added an assistant chatbot with conversation history, typing indicators, new sessions, and message composition.
    • Added message acknowledgments, moderation tools, retries, offline outbox support, and notification deep links.
  • Localization
    • Added chat and assistant translations across supported languages.
  • Bug Fixes
    • Improved chat connection recovery and reliable navigation from notifications.

@Resgrid-Bot

This comment has been minimized.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@ucswift, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 36d25e99-9fb6-46f4-8800-a1fab02c7ebe

📥 Commits

Reviewing files that changed from the base of the PR and between a9d7038 and bf6c8a6.

📒 Files selected for processing (13)
  • src/app/(app)/chat.tsx
  • src/app/(app)/chatbot.tsx
  • src/app/chat/[channelId].tsx
  • src/app/chat/thread/[messageId].tsx
  • src/components/chat/chat-utils.ts
  • src/components/chat/message-actions-sheet.tsx
  • src/components/chat/message-bubble.tsx
  • src/components/chat/message-composer.tsx
  • src/components/chat/new-conversation-sheet.tsx
  • src/components/chat/typing-indicator.tsx
  • src/components/sidebar/side-menu.tsx
  • src/models/v4/chat/index.ts
  • src/stores/chat/store.ts
📝 Walkthrough

Walkthrough

The change adds a complete chat platform with typed APIs, persisted optimistic messaging, SignalR events, chatbot sessions, channel and thread screens, message actions, attachments, moderation, push deep links, navigation, and localization.

Changes

Chat platform

Layer / File(s) Summary
Chat contracts and API
env.js, src/models/v4/chat/*, src/api/chat/*, src/services/signalr.service.ts
Adds chat request, response, event, enum, chatbot, outbox, environment, and API contracts.
Persisted chat state and realtime connectivity
src/stores/chat/store.ts, src/stores/signalr/signalr-store.ts, src/hooks/use-signalr-lifecycle.ts, src/app/(app)/_layout.tsx
Adds optimistic outbox handling, message reconciliation, chat events, typing and presence state, chat hub connection, heartbeats, and lifecycle integration.
Chat message and composer components
src/components/chat/*
Adds reusable channel utilities, message rendering, composer controls, GIF search, message actions, typing indicators, acknowledgements, and conversation creation.
Chat, chatbot, channel, and thread screens
src/app/(app)/chat.tsx, src/app/(app)/chatbot.tsx, src/app/chat/[channelId].tsx, src/app/chat/thread/[messageId].tsx
Adds chat navigation, chatbot sessions, channel conversations, thread replies, pagination, attachments, reactions, moderation, and message actions.
Navigation, notifications, and localization
src/components/sidebar/side-menu.tsx, src/services/push-notification.ts, src/lib/navigation.ts, src/translations/*, pnpm-workspace.yaml
Adds chat routes, retryable push deep links, translated chat and assistant labels, reauthentication strings, and explicit build permissions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant ChatScreen
  participant ChatStore
  participant ChatAPI
  participant ChatHub

  User->>ChatScreen: open channel and compose message
  ChatScreen->>ChatStore: send message
  ChatStore->>ChatAPI: persist optimistic message
  ChatStore->>ChatHub: join channel and send typing state
  ChatHub-->>ChatStore: receive message and receipt events
  ChatStore-->>ChatScreen: reconcile messages and update indicators
  ChatScreen-->>User: render conversation state
Loading

Possibly related PRs

  • Resgrid/Dispatch#113: Both changes modify app initialization, SignalR state, sidebar navigation, and translation infrastructure.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary Chat and Chatbot functionality introduced by the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pnpm-workspace.yaml (1)

1-7: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Replace every unresolved allowBuilds placeholder with an explicit boolean.

Each entry currently uses the pnpm-generated placeholder and remains unapproved while it stays unchanged. This can keep install-time build scripts blocked for @react-buoy/shared-ui, @sentry/cli, electron, electron-winstaller, or postinstall-postinstall.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pnpm-workspace.yaml` around lines 1 - 7, Replace the pnpm-generated “set this
to true or false” values under allowBuilds with explicit boolean values for
`@react-buoy/shared-ui`, `@sentry/cli`, electron, electron-winstaller, and
postinstall-postinstall, selecting approval according to each package’s required
install-time build behavior.
🟠 Major comments (19)
src/services/push-notification.ts-29-34 (1)

29-34: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Accept both chat deep-link prefix cases.

handleChatDeepLink currently only matches t: and g:. Existing notification payload tests use T:9101 and G:1121; current uppercase notifications return false and do not navigate to the chat route. Accept uppercase chat prefixes too, or normalize only the chat prefix before navigating. Add response tests for handleChatDeepLink with both cases.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/push-notification.ts` around lines 29 - 34, Update
handleChatDeepLink to accept both lowercase and uppercase chat prefixes (t:, g:,
T:, and G:) while preserving the existing channelId extraction and navigation
behavior. Normalize or extend only the prefix matching, and add response tests
covering lowercase and uppercase inputs.
src/services/push-notification.ts-238-241 (1)

238-241: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Queue chat notification deep-links until the router is ready.

handleNotificationResponse calls handleChatDeepLink, which returns false and discards the link when router.push is not ready during startup. Store or wait with useRootNavigationState before navigating so a tapped notification response does not open the app to the initial app screen without opening the intended chat.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/push-notification.ts` around lines 238 - 241, Update
handleNotificationResponse and the handleChatDeepLink flow to defer chat
deep-links until the root navigation state indicates the router is ready, rather
than discarding links when router.push cannot run during startup. Preserve each
tapped eventCode and navigate to the intended chat once navigation becomes
available.
src/api/chat/chat.ts-188-198 (1)

188-198: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Remove the manual Content-Type header on the attachment upload.

uploadAttachment posts a React Native FormData object, but sets headers: { 'Content-Type': 'multipart/form-data' } without a boundary. Let Axios/runtime set this header for FormData so the multipart body has a valid boundary.

🛠️ Proposed fix
   const response = await api.post<ChatAttachmentUploadedResult>(`${CHAT}/UploadAttachment`, form, {
     params: { channelId, messageId },
-    headers: { 'Content-Type': 'multipart/form-data' },
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/chat/chat.ts` around lines 188 - 198, Remove the manually specified
Content-Type header from the api.post options in uploadAttachment, allowing
Axios/runtime to set the multipart FormData header with its boundary; preserve
the existing params and request body.
src/stores/chat/store.ts-306-347 (1)

306-347: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persisted outbox items cannot rebuild their optimistic bubble after a restart.

Only outbox is persisted (line 750); messagesByChannel is not. On app restart with pending items, drainOutbox resends them, but no optimistic bubble exists in the meantime. The user sees the message disappear until the server row arrives. Two supporting defects:

  • sendMessage never sets outboxItem.SenderDisplayName, although ChatOutboxItem documents that field as the snapshot for optimistic rendering. The field is currently dead, and the optimistic message hardcodes SenderDisplayName: '' (line 328).
  • args.localAttachmentUri is stored only on the in-memory optimistic message (line 341), never in outboxItem. An image queued while offline loses its local URI on restart, so the resend posts an Image message with no attachment and no way to recover the file.

Persist the display name and the attachment URI on the outbox item, and rehydrate optimistic bubbles from the outbox on store init.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 306 - 347, Update sendMessage to
persist SenderDisplayName and localAttachmentUri on ChatOutboxItem, then reuse
those fields when constructing the optimistic message instead of hardcoded or
transient values. During store initialization, rebuild pending optimistic
bubbles from persisted outbox items—including their channel, sender, message,
metadata, and attachment fields—before drainOutbox resends them, using the
existing optimistic message/upsert flow.
src/stores/chat/store.ts-358-363 (1)

358-363: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

drainOutbox retries permanently failed messages forever with no backoff or re-entrancy guard.

handleChatConnected (line 719) calls drainOutbox on every chat hub connect and reconnect. drainOutbox resends every queued item unconditionally. Three consequences:

  • An item that can never succeed stays queued forever. A 400 on an oversized body or a 403/404 on a deleted channel is caught by sendOutboxItem, marked failed, and kept in outbox (line 795). Each reconnect retries it again. On a flapping connection this is an unbounded retry loop against the API with no backoff.
  • The outbox grows without bound. Nothing evicts items by age or attempt count, and the queue is persisted, so it survives restarts.
  • No re-entrancy guard exists. Two overlapping onChatConnected events run drainOutbox concurrently over the same snapshot and double-post every item. ClientMessageId prevents duplicate server rows, but the duplicate requests are still sent.

Add an Attempts counter and a LastAttemptAt field to ChatOutboxItem in src/models/v4/chat/outbox.ts. Skip items that exceed a maximum attempt count or a maximum age, distinguish permanent 4xx failures from transient ones, and add an isDraining guard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 358 - 363, The outbox drain needs
bounded retries and concurrency protection. Extend ChatOutboxItem with Attempts
and LastAttemptAt, update sendOutboxItem to record attempts and timestamps,
classify permanent 4xx failures so they are removed or no longer retried, and
skip items exceeding the configured attempt or age limits. Add an isDraining
guard around drainOutbox so overlapping handleChatConnected calls cannot process
the same snapshot concurrently.
src/stores/chat/store.ts-544-557 (1)

544-557: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

chatbotTyping can stay true indefinitely.

sendChatbotMessage sets chatbotTyping: true and clears it in only two places: the send-failure catch (line 555) and handleChatbotMessageReceived (line 675). The send itself succeeds even when the asynchronous bot reply never arrives, for example on a bot-side error, a dropped chatbotMessageReceived SignalR event, or a disconnect between the send and the reply. In those cases the typing indicator stays visible with no recovery path, because the flag is not reset on reconnect either.

Add a timeout that clears chatbotTyping after a bounded wait, and clear it in handleChatConnected.

Note on line 554: markOutboxFailed is called for a chatbot message that was never added to outbox. The call works only because the helper patches messagesByChannel by ClientMessageId. Rename the helper or use patchMessage directly, so the intent is clear.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 544 - 557, The chatbot typing state
needs bounded recovery and clearer failure handling. In sendChatbotMessage, add
a bounded timeout after setting chatbotTyping that resets it if no reply
arrives, clear the state in handleChatConnected, and preserve existing
reply/failure cleanup. Rename markOutboxFailed or replace it with patchMessage
for the chatbot failure path so it clearly updates the failed message rather
than implying an outbox entry.
src/stores/chat/store.ts-219-228 (1)

219-228: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Store actions swallow every failure with no state the UI can render.

fetchChannels catches the error, logs it, and clears the loading flag. The state carries no error field, so a failed fetch is indistinguishable from an empty channel list. The same pattern applies to loadInitialMessages (line 251), loadOlderMessages (line 279), editMessage (line 371), deleteMessage (line 380), moderatorDeleteMessage (line 389), addReaction (line 413), removeReaction (line 432), acknowledgeMessage (line 441), togglePin (line 478), and flagMessage (line 486).

The optimistic actions are the higher risk. addReaction, removeReaction, togglePin, and deleteMessage mutate local state first and never roll back on failure. The user sees a reaction or a deletion that the server rejected, and the local state stays wrong until a full reload.

Add an error slice (or a per-action error) so screens can show feedback, and roll back the optimistic mutation in the catch of addReaction, removeReaction, and togglePin.

As per coding guidelines: "Handle errors gracefully and provide user feedback".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 219 - 228, Update the chat store state
and actions to expose fetch/mutation failures through an error slice or
per-action errors, clearing errors on successful operations so the UI can
provide feedback; apply this consistently to fetchChannels, loadInitialMessages,
loadOlderMessages, editMessage, deleteMessage, moderatorDeleteMessage,
addReaction, removeReaction, acknowledgeMessage, togglePin, and flagMessage. In
the catch blocks for addReaction, removeReaction, and togglePin, restore the
exact pre-mutation state before recording the error; preserve loading-state
cleanup and existing logging.

Source: Coding guidelines

src/stores/chat/store.ts-589-608 (1)

589-608: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

handleMessageReceived inflates UnreadCount on duplicate delivery.

upsertMessage deduplicates the message itself, but line 600 increments UnreadCount unconditionally for any message that is not own and not in the active channel. The same message can reach this handler more than once:

  • SignalR redelivers the event after a reconnect.
  • loadNewerMessages already fetched the message during handleChatConnected (line 722), and the hub event arrives afterwards.

Each delivery adds one to the badge, so the unread count drifts above the real number and only corrects on the next fetchChannels.

Increment only when the message is new to the list.

🐛 Proposed fix
         set((s) => {
-          const list = upsertMessage(s.messagesByChannel[msg.ChatChannelId] ?? [], { ...msg, _localStatus: 'sent' });
+          const previous = s.messagesByChannel[msg.ChatChannelId] ?? [];
+          const isNew = !previous.some((m) => m.ChatMessageId === msg.ChatMessageId || (!!msg.ClientMessageId && m.ClientMessageId === msg.ClientMessageId));
+          const list = upsertMessage(previous, { ...msg, _localStatus: 'sent' });
           const channels = s.channels.map((c) =>
             c.ChatChannelId === msg.ChatChannelId
-              ? { ...c, LastMessageSeq: Math.max(c.LastMessageSeq, msg.MessageSeq), LastMessageOn: msg.SentOn, UnreadCount: isActive || isOwn ? c.UnreadCount : c.UnreadCount + 1 }
+              ? {
+                  ...c,
+                  LastMessageSeq: Math.max(c.LastMessageSeq, msg.MessageSeq),
+                  LastMessageOn: msg.SentOn,
+                  UnreadCount: isActive || isOwn || !isNew ? c.UnreadCount : c.UnreadCount + 1,
+                }
               : c
           );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 589 - 608, Update
handleMessageReceived to determine whether msg.MessageSeq is already present in
the channel’s existing message list before calling set. Increment UnreadCount
only for a new, non-own message in an inactive channel; preserve upsertMessage
deduplication and leave the count unchanged for duplicate deliveries.
src/stores/signalr/signalr-store.ts-622-632 (1)

622-632: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A stale isChatHubConnected flag permanently blocks chat reconnection.

Line 624 returns early when isChatHubConnected is true, but nothing reconciles that flag against the real hub state. checkConnectionState (line 502) reconciles only isUpdateHubConnected. The flag is cleared only by disconnectChatHub.

The chat hub can drop without disconnectChatHub running. signalr.service.ts gives up after MAX_RECONNECT_ATTEMPTS and deletes the connection and its config (lines 789-802) without notifying this store. isChatHubConnected then stays true while the hub is gone. Every later connectChatHub call returns immediately, including the handleAppResume call in src/hooks/use-signalr-lifecycle.ts. Chat realtime stays dead until the app restarts.

Check the real connection state in the guard, as signalRService already exposes it.

🐛 Proposed fix
   connectChatHub: async () => {
     try {
-      if (get().isChatHubConnected) {
+      if (get().isChatHubConnected && signalRService.isHubConnected(Env.CHAT_HUB_NAME)) {
         return;
       }
+      // The flag is stale; the hub dropped without an explicit disconnect.
+      if (get().isChatHubConnected) {
+        logger.warn({ message: 'Chat hub flag was stale, reconnecting' });
+        stopChatHeartbeat();
+        unregisterChatHubHandlers();
+        set({ isChatHubConnected: false });
+      }

Also consider extending checkConnectionState to reconcile isChatHubConnected.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/signalr/signalr-store.ts` around lines 622 - 632, Update
connectChatHub to guard against the actual chat hub state exposed by
signalRService rather than relying solely on the stale isChatHubConnected flag;
only return early when the stored flag and live connection both indicate an
active chat hub, otherwise continue reconnection. Also extend
checkConnectionState to reconcile isChatHubConnected with the service’s current
chat connection state.
src/stores/signalr/signalr-store.ts-668-693 (1)

668-693: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The connectChatHub failure path leaves chat handlers registered and skips the recovery hook.

Two problems in this block.

First, the catch at line 688 records the error but performs no cleanup. If signalRService.invoke(..., 'Connect') at line 677 rejects, the 15 handlers registered at lines 662-674 stay subscribed on signalRService, and any heartbeat from a previous session keeps running. isChatHubConnected stays false, so the store reports "not connected" while the chat store still receives and applies hub events. Call stopChatHeartbeat() and unregisterChatHubHandlers() in the catch.

Second, line 678 sets isChatHubConnected: true immediately after the Connect invoke, independent of the onChatConnected event. handleChatConnected runs only from the event handler at line 671. If the hub does not raise onChatConnected, the store reports a connected chat hub, but fetchChannels, fetchPendingAcks, and drainOutbox never run. Queued outbound messages stay unsent. Call the recovery path after a successful Connect invoke as well, and make handleChatConnected idempotent.

🛡️ Proposed fix
       // Announce chat presence to the hub, then begin the periodic heartbeat.
       await signalRService.invoke(Env.CHAT_HUB_NAME, 'Connect');
       set({ isChatHubConnected: true });
+      // The hub may not raise onChatConnected; run recovery from the invoke result too.
+      useChatStore.getState().handleChatConnected();
 
       stopChatHeartbeat();
       chatHeartbeatTimer = setInterval(() => {
         signalRService.invoke(Env.CHAT_HUB_NAME, 'Heartbeat').catch(() => {
           // Heartbeat is best-effort; ignore transient failures.
         });
       }, CHAT_HEARTBEAT_INTERVAL_MS);
 
       logger.info({ message: 'Chat hub handlers registered successfully' });
     } catch (error) {
       const err = error instanceof Error ? error : new Error('Unknown error occurred');
+      // Do not leave handlers or the heartbeat attached to a failed connection.
+      stopChatHeartbeat();
+      unregisterChatHubHandlers();
+      set({ isChatHubConnected: false });
       logger.error({ message: 'Failed to connect to chat SignalR hub', context: { error: err } });
       set({ error: err });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/signalr/signalr-store.ts` around lines 668 - 693, Update
connectChatHub so its catch path calls stopChatHeartbeat() and
unregisterChatHubHandlers() before recording the error. After a successful
Connect invoke, trigger the same chat-connected recovery path as the
onChatConnected handler instead of only setting isChatHubConnected, and make
handleChatConnected idempotent so event and invoke-based paths cannot duplicate
recovery work.
src/stores/chat/store.ts-455-471 (1)

455-471: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

markChannelRead advances the dedupe marker before the request succeeds, so a failed markRead is never retried.

Line 459 sets lastMarkedSeq for the channel before the chatApi.markRead call. If markRead rejects, the catch at line 468 only logs. The guard at line 458 then blocks every later call for that sequence, because lastMarkedSeq already holds it. The local UnreadCount is also set to 0 (line 462). Result: the channel reads as read on the device but stays unread on the server until a newer message arrives.

Advance lastMarkedSeq only after the request succeeds, or roll it back in the catch.

🐛 Proposed fix
         const seq = highestRealSeq(get().messagesByChannel[channelId]);
         if (seq <= 0) return;
-        if ((lastMarkedSeq.get(channelId) ?? 0) >= seq) return;
-        lastMarkedSeq.set(channelId, seq);
+        const previousMarkedSeq = lastMarkedSeq.get(channelId) ?? 0;
+        if (previousMarkedSeq >= seq) return;
+        lastMarkedSeq.set(channelId, seq);
 
         set((s) => ({
           channels: s.channels.map((c) => (c.ChatChannelId === channelId ? { ...c, UnreadCount: 0, MyLastReadSeq: seq } : c)),
         }));
 
         void safeInvoke('MarkRead', channelId, seq);
         try {
           await chatApi.markRead(channelId, { Seq: seq });
         } catch (error) {
+          // Allow a later attempt to re-mark this sequence.
+          lastMarkedSeq.set(channelId, previousMarkedSeq);
           logger.debug({ message: 'chat: markRead failed', context: { error, channelId } });
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 455 - 471, Update markChannelRead so
lastMarkedSeq is advanced only after chatApi.markRead succeeds, or restore its
previous value when the request fails. Ensure failed requests remain retryable
and avoid permanently treating the channel as read locally until the server call
succeeds.
src/app/chat/thread/[messageId].tsx-62-64 (1)

62-64: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The GIF and image buttons do nothing in a thread.

handleSendGif has an empty body, and onSendImage is () => undefined. MessageComposer still renders both controls as active. The image control calls handlePickImage, which requests photo-library permission and opens the picker, then discards the selected image. The user receives no feedback.

Add optional capability flags to MessageComposer and hide the unsupported controls in the thread screen.

🐛 Proposed change

In src/components/chat/message-composer.tsx:

   onSendText: (body: string, urgent: boolean) => void;
-  onSendImage: (uri: string, urgent: boolean) => void;
-  onSendLocation: (latitude: number, longitude: number, urgent: boolean) => void;
-  onOpenGif: () => void;
+  onSendImage?: (uri: string, urgent: boolean) => void;
+  onSendLocation?: (latitude: number, longitude: number, urgent: boolean) => void;
+  onOpenGif?: () => void;

Render each control only when its handler is present.

In this file:

-  const handleSendGif = useCallback(() => {
-    // GIFs in threads are sent as text-less messages via the composer's gif flow; kept minimal here.
-  }, []);
         <MessageComposer
           onSendText={handleSendText}
-          onSendImage={() => undefined}
           onSendLocation={handleSendLocation}
-          onOpenGif={handleSendGif}
           onTyping={() => undefined}
           placeholder={t('chat.reply_placeholder')}
         />

Also applies to: 121-128

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/chat/thread/`[messageId].tsx around lines 62 - 64, Update
MessageComposer to accept optional GIF and image handler capabilities and render
each corresponding control only when its handler is provided. In the thread
screen, stop exposing the unsupported controls by omitting the empty
handleSendGif and onSendImage callbacks from MessageComposer, while preserving
supported composer behavior.
src/app/chat/thread/[messageId].tsx-30-37 (1)

30-37: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Fetch the root message and report a thread load failure.

root is read only from messagesByChannel[channelId]. A push deep link opens this screen without a populated channel cache, so the original message is not rendered. The getThread failure path also only writes to the log, and the screen shows an empty list with no error state and no loading state.

Load the root message from the API when it is absent from the cache. Pass an AbortSignal to getThread; the API supports one (src/api/chat/chat.ts:111-117). Show a loading state and an error state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/chat/thread/`[messageId].tsx around lines 30 - 37, Update the thread
screen’s root-message and reply-loading flow: when `root` is absent from
`channelMessages`, fetch the original message from the API and render it once
loaded; create an `AbortController`, pass its signal to `getThread`, and abort
it during effect cleanup. Track loading and failure state around the request,
displaying appropriate loading and error UI instead of an empty thread, while
preserving the existing successful reply rendering.
src/components/chat/message-composer.tsx-117-132 (1)

117-132: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply disabled to the attachment, GIF, and location controls.

src/app/chat/[channelId].tsx passes disabled={channel?.IsLocked && !isModerator}. Only TextareaInput and the send button honor that value. In a locked channel the user can still send an image, a GIF, or a location, because those handlers call onSendImage, onOpenGif, and onSendLocation directly.

🐛 Proposed fix
-        <Pressable className="p-2" onPress={handlePickImage} accessibilityLabel={t('chat.add_image')}>
+        <Pressable className="p-2" onPress={handlePickImage} disabled={disabled} accessibilityLabel={t('chat.add_image')}>
           <ImagePlus size={22} color="`#6b7280`" />
         </Pressable>
-        <Pressable className="p-2" onPress={onOpenGif} accessibilityLabel={t('chat.add_gif')}>
+        <Pressable className="p-2" onPress={onOpenGif} disabled={disabled} accessibilityLabel={t('chat.add_gif')}>
           <Sparkles size={22} color="`#6b7280`" />
         </Pressable>
-        <Pressable className="p-2" onPress={handleShareLocation} accessibilityLabel={t('chat.share_location')}>
+        <Pressable className="p-2" onPress={handleShareLocation} disabled={disabled} accessibilityLabel={t('chat.share_location')}>
           <MapPin size={22} color="`#6b7280`" />
         </Pressable>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/message-composer.tsx` around lines 117 - 132, Apply the
existing disabled state to the image, GIF, and location Pressable controls in
the message composer, alongside their current handlers. Ensure the
locked-channel condition passed from the chat screen prevents handlePickImage,
onOpenGif, and handleShareLocation from being invoked, while preserving current
behavior when enabled.
src/app/chat/[channelId].tsx-142-165 (1)

142-165: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The image upload is skipped silently when the confirmed message is not found.

The code awaits sendMessage, then searches the store for a message whose _localAttachmentUri matches and whose id is not prefixed with local-. If the send failed, or if the store has not yet replaced the optimistic row, sent is undefined. The function then returns without uploading and without user feedback. The bubble keeps only the device-local uri, so the image disappears on the next app start and other participants never receive it.

Return the confirmed message id from sendMessage in src/stores/chat/store.ts, then upload against that id. Report a failure to the user when no confirmed id is available.

🛡️ Interim fix for the missing feedback
       const sent = [...list].reverse().find((m) => m._localAttachmentUri === uri && !m.ChatMessageId.startsWith('local-'));
-      if (sent) {
-        try {
-          await uploadAttachment(channelId, sent.ChatMessageId, { uri, name, type: 'image/jpeg' });
-        } catch {
-          useToastStore.getState().showToast('error', t('chat.attachment_failed'));
-        }
+      if (!sent) {
+        useToastStore.getState().showToast('error', t('chat.attachment_failed'));
+        return;
+      }
+      try {
+        await uploadAttachment(channelId, sent.ChatMessageId, { uri, name, type: 'image/jpeg' });
+      } catch {
+        useToastStore.getState().showToast('error', t('chat.attachment_failed'));
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/chat/`[channelId].tsx around lines 142 - 165, Update sendMessage in
the chat store to return the confirmed server message ID, then use that returned
ID directly in handleSendImage for uploadAttachment instead of searching
messagesByChannel. When sendMessage does not provide a confirmed ID, show the
existing attachment failure toast and skip the upload.
src/app/(app)/chat.tsx-27-42 (1)

27-42: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not define Leading during render.

Leading is created on every ChannelRow render. React treats it as a new component type each time and unmounts the previous subtree. Inline the JSX or move the component to module scope and pass channel as a prop.

♻️ Proposed fix
+function ChannelLeading({ channel }: { channel: ChatChannelResultData }) {
+  if (channel.ChannelType === ChatChannelType.DirectMessage) {
+    return (
+      <Avatar size="md">
+        <AvatarFallbackText>{getChannelDisplayName(channel)}</AvatarFallbackText>
+      </Avatar>
+    );
+  }
+  const isIncident = channel.ChannelType === ChatChannelType.Incident || channel.ChannelType === ChatChannelType.IncidentLane || channel.ChannelType === ChatChannelType.IncidentCommand;
+  const Icon = channel.ChannelType === ChatChannelType.Chatbot ? Sparkles : isIncident ? Network : Users;
+  return (
+    <Box className="size-10 items-center justify-center rounded-full bg-primary-100">
+      <Icon size={20} color="`#2563eb`" />
+    </Box>
+  );
+}
+
 function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) {
   const unread = channel.UnreadCount > 0;
-  const isDm = channel.ChannelType === ChatChannelType.DirectMessage;
-
-  const Leading = () => {
-    ...
-  };

Then render <ChannelLeading channel={channel} /> in place of <Leading />.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(app)/chat.tsx around lines 27 - 42, Move the Leading component out
of the ChannelRow render scope to module scope, accepting channel (and the
existing isDm behavior) as props, then render it through the stable
ChannelLeading component. Preserve the current avatar, incident/chatbot icon
selection, and styling.

Source: Linters/SAST tools

src/components/chat/gif-picker-sheet.tsx-30-54 (1)

30-54: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cancel in-flight GIF searches and clear the debounce timer.

runSearch does not pass an AbortSignal, and searchGifs accepts one (src/api/chat/chat.ts:230-236). A slow earlier request can resolve after a later request and overwrite gifs with stale results. The debounce timer is also never cleared on unmount, so a search runs after the sheet closes.

♻️ Proposed fix
   const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const abortRef = useRef<AbortController | null>(null);
 
   const runSearch = useCallback(async (q: string) => {
+    abortRef.current?.abort();
+    const controller = new AbortController();
+    abortRef.current = controller;
     setLoading(true);
     try {
-      const response = await searchGifs(q || undefined, 24, 0);
+      const response = await searchGifs(q || undefined, 24, 0, controller.signal);
       setGifs(response.Data ?? []);
     } catch (error) {
+      if (controller.signal.aborted) return;
       logger.debug({ message: 'chat: gif search failed', context: { error } });
       setGifs([]);
     } finally {
-      setLoading(false);
+      if (!controller.signal.aborted) setLoading(false);
     }
   }, []);
 
+  useEffect(
+    () => () => {
+      if (debounceRef.current) clearTimeout(debounceRef.current);
+      abortRef.current?.abort();
+    },
+    []
+  );
+
   useEffect(() => {
     if (!isOpen) return;
     runSearch('');
   }, [isOpen, runSearch]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/gif-picker-sheet.tsx` around lines 30 - 54, Update
runSearch to create and pass an AbortSignal to searchGifs, aborting the previous
request before starting a new one and ignoring expected abort failures so stale
results cannot update gifs. In the useEffect cleanup for isOpen, clear
debounceRef and abort any active request so no search runs after unmount or
closure.
src/components/chat/chat-utils.ts-99-102 (1)

99-102: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

hasLink returns alternating results because URL_REGEX is global.

URL_REGEX has the g flag. RegExp.prototype.test advances lastIndex on a match and keeps it across calls. A second call with the same body starts the search after the previous match and can return false. Use a separate non-global regex for the predicate.

🐛 Proposed fix
 const URL_REGEX = /(https?:\/\/[^\s]+)/g;
+const URL_TEST_REGEX = /https?:\/\/[^\s]+/;
 
 export interface TextSegment {
 export function hasLink(body?: string | null): boolean {
   if (!body) return false;
-  return URL_REGEX.test(body);
+  return URL_TEST_REGEX.test(body);
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/chat-utils.ts` around lines 99 - 102, Update hasLink to
use a non-global regular expression for its predicate check instead of the
shared global URL_REGEX, ensuring repeated calls with the same body return
consistent results. Preserve the existing false result for empty or null bodies.
src/components/chat/message-composer.tsx-43-54 (1)

43-54: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Send the typing signal once, and stop it automatically.

handleChange calls onTyping(true) on every keystroke. In src/app/chat/[channelId].tsx that prop calls useChatStore.getState().sendTyping(channelId, isTyping), so each character produces one realtime call. typingActive is set but never used to suppress repeats. The component also never stops typing on unmount, so other users keep seeing the indicator after the screen closes.

♻️ Proposed fix
   const typingActive = useRef(false);
+  const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
 
   const stopTyping = useCallback(() => {
+    if (typingTimeoutRef.current) {
+      clearTimeout(typingTimeoutRef.current);
+      typingTimeoutRef.current = null;
+    }
     if (typingActive.current) {
       typingActive.current = false;
       onTyping(false);
     }
   }, [onTyping]);
 
+  useEffect(() => stopTyping, [stopTyping]);
+
   const handleChange = useCallback(
     (value: string) => {
       setText(value);
       if (value.length > 0) {
-        typingActive.current = true;
-        onTyping(true);
+        if (!typingActive.current) {
+          typingActive.current = true;
+          onTyping(true);
+        }
+        if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current);
+        typingTimeoutRef.current = setTimeout(stopTyping, 3000);
       } else {
         stopTyping();
       }
     },
     [onTyping, stopTyping]
   );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/message-composer.tsx` around lines 43 - 54, Update
handleChange to call onTyping(true) only when typingActive.current is false,
then set the flag after sending; retain stopTyping for empty input and ensure
cleanup invokes stopTyping on component unmount so the typing indicator is
cleared.
🟡 Minor comments (8)
src/translations/en.json-394-394 (1)

394-394: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle the thread_replies singular case.

message-bubble.tsx passes message.ThreadReplyCount directly to t('chat.thread_replies', { count: message.ThreadReplyCount }). If one reply exists, this renders 1 replies. Split this into a singular label for one reply and a plural label for multiple replies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/translations/en.json` at line 394, Update the chat translation and its
usage in message-bubble.tsx so a ThreadReplyCount of 1 renders a singular reply
label, while counts greater than 1 use the plural label. Adjust the
thread_replies translation key or add the necessary singular key, and select the
appropriate key where t receives message.ThreadReplyCount.
src/services/push-notification.ts-32-34 (1)

32-34: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Constrain and pass channelId as a route parameter.

handleChatDeepLink currently builds /chat/${channelId} and the parser accepts any remainder after the prefix. Add a chat event-code contract, validate the channel ID before navigation, and call router.push({ pathname: '/chat/[channelId]', params: { channelId } }) so values such as spaces or / are handled as one parameter instead of malformed segments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/push-notification.ts` around lines 32 - 34, Update
handleChatDeepLink to enforce the chat event-code contract and validate the
parsed channelId before navigation, rejecting values that do not meet the
allowed channel-ID constraints. Replace the interpolated /chat/${channelId}
navigation with router.push using pathname '/chat/[channelId]' and params
containing channelId, while preserving the existing handling for invalid deep
links.
src/stores/chat/store.ts-1-22 (1)

1-22: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The test check fails on import and export ordering in two files. The repository sort rule is not applied to the new chat modules. One lint autofix run resolves both sites.

  • src/stores/chat/store.ts#L1-L22: run the lint autofix to sort the import statements, including the named type imports inside the @/models/v4/chat block, where ChatMemberResultData and ChatMentionInput are placed after ChatMessageType.
  • src/models/v4/chat/index.ts#L1-L6: run the lint autofix to sort the barrel exports into ASCII order: chatEnums, chatEvents, chatInputs, chatModels, chatbotModels, outbox.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 1 - 22, Apply the repository’s lint
autofix to both affected sites: reorder imports in src/stores/chat/store.ts,
including the named imports from `@/models/v4/chat`, and reorder barrel exports in
src/models/v4/chat/index.ts into ASCII order: chatEnums, chatEvents, chatInputs,
chatModels, chatbotModels, outbox.

Source: Linters/SAST tools

src/components/chat/chat-utils.ts-40-44 (1)

40-44: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Translate the channel display fallbacks.

'Direct Message' and 'Channel' render in the channel list and in the conversation header. Both strings bypass react-i18next. Pass a translator or return a translation key so callers resolve the label with t().

♻️ Proposed change
-export function getChannelDisplayName(channel: ChatChannelResultData): string {
+export function getChannelDisplayNameKey(channel: ChatChannelResultData): { name?: string; key?: string } {
   if (channel.Name && channel.Name.trim().length > 0) return channel.Name;
-  if (channel.ChannelType === ChatChannelType.DirectMessage) return 'Direct Message';
-  return 'Channel';
+  if (channel.ChannelType === ChatChannelType.DirectMessage) return { key: 'chat.direct_message' };
+  return { key: 'chat.channel' };
 }

As per coding guidelines: "Ensure all text is wrapped in t() from react-i18next for translations with the dictionary files stored in src/translations".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/chat-utils.ts` around lines 40 - 44, Update
getChannelDisplayName to avoid returning hardcoded fallback labels for
DirectMessage and generic channels; either accept and use a react-i18next
translator or return translation keys that both the channel list and
conversation header resolve through t(). Preserve the existing named-channel
behavior and ensure both fallback strings are sourced from the translation
dictionaries.

Source: Coding guidelines

src/components/chat/message-bubble.tsx-88-100 (1)

88-100: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Validate the location metadata before you build the map URL.

parseLocationMetadata casts unvalidated JSON to ChatLocationMetadata. MetadataJson arrives from other users through the API, so Latitude and Longitude can hold arbitrary strings. The values are interpolated into the URL without encoding. Check that both values are finite numbers, then encode them.

🛡️ Proposed fix
     if (message.MessageType === ChatMessageType.Location) {
       const loc = parseLocationMetadata(message.MetadataJson);
-      if (loc) {
+      const latitude = Number(loc?.Latitude);
+      const longitude = Number(loc?.Longitude);
+      if (loc && Number.isFinite(latitude) && Number.isFinite(longitude)) {
         return (
-          <Pressable onPress={() => Linking.openURL(`https://maps.google.com/?q=${loc.Latitude},${loc.Longitude}`)}>
+          <Pressable onPress={() => Linking.openURL(`https://maps.google.com/?q=${encodeURIComponent(`${latitude},${longitude}`)}`)}>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/message-bubble.tsx` around lines 88 - 100, Update the
location branch in the message rendering logic around parseLocationMetadata to
accept metadata only when Latitude and Longitude are finite numbers, rejecting
invalid values before rendering the Pressable. When constructing the Google Maps
URL in the Pressable handler, encode both validated coordinate values before
interpolation.
src/components/chat/new-conversation-sheet.tsx-56-59 (1)

56-59: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Report the recipient load failure to the user.

A failed getRecipients call only writes to the log. recipients stays empty, so the sheet shows chat.no_people. The user cannot tell an empty roster from a network error. Use the same toast pattern as startDirectMessage.

♻️ Proposed fix
     getRecipients(true, false)
       .then((result) => setRecipients((result.Data ?? []).filter(isPersonRecipient)))
-      .catch((error) => logger.error({ message: 'chat: failed to load recipients', context: { error } }))
+      .catch((error) => {
+        logger.error({ message: 'chat: failed to load recipients', context: { error } });
+        useToastStore.getState().showToast('error', t('chat.load_people_failed'));
+      })
       .finally(() => setLoading(false));
-  }, [isOpen]);
+  }, [isOpen, t]);

Add the chat.load_people_failed key to src/translations/en.json.

As per coding guidelines: "Handle errors gracefully and provide user feedback".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/new-conversation-sheet.tsx` around lines 56 - 59, Update
the getRecipients failure handler in the new-conversation sheet to retain the
existing logging and show the user an error toast using the same toast pattern
as startDirectMessage, with the chat.load_people_failed translation key. Add
that key to the English translations.

Source: Coding guidelines

src/app/chat/[channelId].tsx-103-106 (1)

103-106: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Expired typing entries stay visible until the next store update.

typingNames reads Date.now() when the memo runs. The memo depends on typing and t only. When a typing entry expires and no further store update arrives, the indicator keeps rendering. Add a periodic tick so the memo re-evaluates.

♻️ Proposed fix
+  const [typingTick, setTypingTick] = useState(0);
+
+  useEffect(() => {
+    if (!typing || typing.length === 0) return;
+    const id = setInterval(() => setTypingTick((prev) => prev + 1), 1000);
+    return () => clearInterval(id);
+  }, [typing]);
+
   const typingNames = useMemo(() => {
     const now = Date.now();
     return (typing ?? []).filter((u) => u.expiresAt > now).map((u) => u.displayName || t('chat.someone'));
-  }, [typing, t]);
+  }, [typing, t, typingTick]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/chat/`[channelId].tsx around lines 103 - 106, Update the typing
indicator logic around typingNames to include a periodic state tick that changes
while the component is mounted, causing the useMemo expiration filter to
re-evaluate even without typing store updates. Add the tick dependency to
typingNames and clean up the interval on unmount, preserving the existing
display-name fallback and expiry filtering.
src/components/chat/message-actions-sheet.tsx-141-151 (1)

141-151: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Hide pin and unpin for deleted messages.

The moderator-delete item at line 160 checks !isDeleted. The pin item does not. A moderator can pin a deleted message, and MessageBubble then renders a pin icon next to the "message deleted" placeholder.

🐛 Proposed fix
-            {isModerator ? (
+            {isModerator && !isDeleted ? (
               <ActionsheetItem
                 onPress={() => {
                   onTogglePin(message, !isPinned);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/message-actions-sheet.tsx` around lines 141 - 151, Update
the moderator pin/unpin action in the message-actions rendering to require both
isModerator and !isDeleted, matching the existing moderator-delete visibility
condition. Keep the current pin toggle behavior unchanged for non-deleted
messages.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1352f0be-6410-41f7-bd64-c5ca7bb6d830

📥 Commits

Reviewing files that changed from the base of the PR and between 9bedcf5 and d522abe.

📒 Files selected for processing (31)
  • env.js
  • pnpm-workspace.yaml
  • src/api/chat/chat.ts
  • src/api/chat/chatbot.ts
  • src/app/(app)/_layout.tsx
  • src/app/(app)/chat.tsx
  • src/app/(app)/chatbot.tsx
  • src/app/chat/[channelId].tsx
  • src/app/chat/thread/[messageId].tsx
  • src/components/chat/ack-banner.tsx
  • src/components/chat/chat-utils.ts
  • src/components/chat/gif-picker-sheet.tsx
  • src/components/chat/message-actions-sheet.tsx
  • src/components/chat/message-bubble.tsx
  • src/components/chat/message-composer.tsx
  • src/components/chat/new-conversation-sheet.tsx
  • src/components/chat/typing-indicator.tsx
  • src/components/sidebar/side-menu.tsx
  • src/hooks/use-signalr-lifecycle.ts
  • src/models/v4/chat/chatEnums.ts
  • src/models/v4/chat/chatEvents.ts
  • src/models/v4/chat/chatInputs.ts
  • src/models/v4/chat/chatModels.ts
  • src/models/v4/chat/chatbotModels.ts
  • src/models/v4/chat/index.ts
  • src/models/v4/chat/outbox.ts
  • src/services/push-notification.ts
  • src/services/signalr.service.ts
  • src/stores/chat/store.ts
  • src/stores/signalr/signalr-store.ts
  • src/translations/en.json

Comment thread src/app/(app)/chat.tsx
@@ -0,0 +1,195 @@
import { type Href, Stack, useFocusEffect, useRouter } from 'expo-router';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔴 Critical | ⚡ Quick win

Run the lint autofix. The import order breaks the pipeline.

yarn check-all fails with simple-import-sort/imports on every file listed below. The shared root cause is that the new files were not passed through the ESLint autofix. Run yarn lint --fix.

  • src/app/(app)/chat.tsx#L1-L1: sort the import block; expo-router, lucide-react-native, react, react-i18next, and react-native must precede the @/ group in the configured order.
  • src/app/(app)/chatbot.tsx#L1-L1: move import useAuthStore from '@/stores/auth/store'; before import { useChatStore } from '@/stores/chat/store';.
  • src/app/chat/[channelId].tsx#L1-L1: move import { Image } from 'expo-image'; into the external group before react-native, move KeyboardAvoidingView above Spinner, and move useAuthStore above useChatStore.
  • src/app/chat/thread/[messageId].tsx#L1-L1: move import useAuthStore from '@/stores/auth/store'; before import { useChatStore } from '@/stores/chat/store';.
  • src/components/chat/chat-utils.ts#L1-L1: sort the two @/ imports.
  • src/components/chat/message-bubble.tsx#L1-L1: sort the import block.
  • src/components/chat/message-actions-sheet.tsx#L1-L1: sort the import block.
🧰 Tools
🪛 GitHub Actions: React Native CI/CD / 3_test.txt

[error] 1-1: ESLint: Imports are not sorted. Run autofix to sort these imports. (simple-import-sort/imports). The 'yarn check-all' command failed during 'yarn run lint'.

🪛 GitHub Actions: React Native CI/CD / test

[error] 1-1: ESLint: Imports are not sorted. Run autofix to sort these imports. (simple-import-sort/imports) Command: yarn check-all

🪛 GitHub Check: test

[failure] 1-1:
Run autofix to sort these imports!

📍 Affects 7 files
  • src/app/(app)/chat.tsx#L1-L1 (this comment)
  • src/app/(app)/chatbot.tsx#L1-L1
  • src/app/chat/[channelId].tsx#L1-L1
  • src/app/chat/thread/[messageId].tsx#L1-L1
  • src/components/chat/chat-utils.ts#L1-L1
  • src/components/chat/message-bubble.tsx#L1-L1
  • src/components/chat/message-actions-sheet.tsx#L1-L1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(app)/chat.tsx at line 1, Run the ESLint autofix to sort imports in
src/app/(app)/chat.tsx (line 1), src/app/(app)/chatbot.tsx (line 1),
src/app/chat/[channelId].tsx (line 1), src/app/chat/thread/[messageId].tsx (line
1), src/components/chat/chat-utils.ts (line 1),
src/components/chat/message-bubble.tsx (line 1), and
src/components/chat/message-actions-sheet.tsx (line 1). Ensure external imports
precede the `@/` group, auth-store imports precede chat-store imports, and the
specified named imports follow the configured order.

Sources: Linters/SAST tools, Pipeline failures

Comment thread src/stores/chat/store.ts
Comment thread src/api/chat/chatbot.ts
* channel over SignalR (chatbotMessageReceived). Idempotent via clientMessageId.
*/
export const sendChatbotMessage = async (text: string, clientMessageId: string) => {
const response = await api.post<ChatbotSendResponse>(`${CHATBOT}/SendChatMessage`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled external HTTP POST call in chatbot.ts violates team rules requiring network calls to be wrapped in try/catch. Wrap the call in a try/catch block, add structured context like op and clientMessageId, and map the error to a domain-level exception.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File src/api/chat/chatbot.ts:

Line 18:

Unhandled external HTTP POST call in `chatbot.ts` violates team rules requiring network calls to be wrapped in try/catch. Wrap the call in a try/catch block, add structured context like `op` and `clientMessageId`, and map the error to a domain-level exception.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/api/chat/chatbot.ts
const CHATBOT = '/Chatbot';

/** Gets (creating if needed) the caller's chatbot conversation channel. */
export const getChatbotChannel = async (signal?: AbortSignal) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Missing JSDoc on getChatbotChannel omits Promise return type and rejection conditions, violating documentation rules. Add @returns {Promise<ChatbotChannelResponse>} and document network/auth rejection conditions.

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/api/chat/chatbot.ts:

Line 8:

Missing JSDoc on `getChatbotChannel` omits Promise return type and rejection conditions, violating documentation rules. Add `@returns {Promise<ChatbotChannelResponse>}` and document network/auth rejection conditions.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/app/(app)/chat.tsx
<VStack className="mb-2">
<Text className="px-4 pb-1 pt-3 text-xs font-semibold uppercase text-typography-400">{title}</Text>
{channels.map((channel) => (
<ChannelRow key={channel.ChatChannelId} channel={channel} onPress={() => onOpen(channel.ChatChannelId)} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Inline arrow function in the onPress JSX prop creates a new function on every render, degrading performance. Move the function definition outside the render method.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File src/app/(app)/chat.tsx:

Line 74:

Inline arrow function in the `onPress` JSX prop creates a new function on every render, degrading performance. Move the function definition outside the render method.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/app/(app)/chat.tsx
const isLoading = useChatStore((s) => s.isLoadingChannels);
const pendingAcks = useChatStore((s) => s.pendingAcks);
const [fabOpen, setFabOpen] = useState(false);
const [newMode, setNewMode] = useState<'dm' | 'group' | null>(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Duplicated TypeScript literal types and runtime string values for conversation modes risk structural drift. Declare const CONVERSATION_MODES = ['dm', 'group'] as const; and derive the type using typeof CONVERSATION_MODES[number].

Kody rule violation: Derive TypeScript types from validation schemas

Prompt for LLM

File src/app/(app)/chat.tsx:

Line 87:

Duplicated TypeScript literal types and runtime string values for conversation modes risk structural drift. Declare `const CONVERSATION_MODES = ['dm', 'group'] as const;` and derive the type using `typeof CONVERSATION_MODES[number]`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/app/(app)/chatbot.tsx
useFocusEffect(
useCallback(() => {
const store = useChatStore.getState();
void store.initChatbot();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled promise rejection occurs because initChatbot() is fired with void and lacks error handling, which can crash the app or hide bugs. Append .catch(err => logger.error('initChatbot failed', { err })) or wrap the await in a try/catch block.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/app/(app)/chatbot.tsx:

Line 34:

Unhandled promise rejection occurs because `initChatbot()` is fired with `void` and lacks error handling, which can crash the app or hide bugs. Append `.catch(err => logger.error('initChatbot failed', { err }))` or wrap the await in a try/catch block.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/app/(app)/chatbot.tsx
import { useChatStore } from '@/stores/chat/store';
import useAuthStore from '@/stores/auth/store';

export default function ChatbotScreen() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Default export reduces maintainability and increases refactoring risks. Change to export function ChatbotScreen() or add a named export alongside the default export if strictly required by expo-router.

Kody rule violation: Avoid default exports

Prompt for LLM

File src/app/(app)/chatbot.tsx:

Line 23:

Default export reduces maintainability and increases refactoring risks. Change to `export function ChatbotScreen()` or add a named export alongside the default export if strictly required by `expo-router`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment on lines +302 to +305
{imageUri ? (
<Center className="w-full p-2">
<Image source={{ uri: imageUri }} style={{ width: '100%', height: 400, borderRadius: 12 }} contentFit="contain" />
</Center>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Missing Authorization header causes the full-screen image preview to silently fail when loading server-hosted chat attachments. Pass the auth header in the preview source using getChatAttachmentImageSource(imageUri) or store both the URI and headers from the original tap.

{imageUri ? (
            <Center className="w-full p-2">
              <Image source={getChatAttachmentImageSource(imageUri)} style={{ width: '100%', height: 400, borderRadius: 12 }} contentFit="contain" />
            </Center>
          ) : null}
Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 302 to 305:

Missing Authorization header causes the full-screen image preview to silently fail when loading server-hosted chat attachments. Pass the auth header in the preview source using `getChatAttachmentImageSource(imageUri)` or store both the URI and headers from the original tap.

Suggested Code:

{imageUri ? (
            <Center className="w-full p-2">
              <Image source={getChatAttachmentImageSource(imageUri)} style={{ width: '100%', height: 400, borderRadius: 12 }} contentFit="contain" />
            </Center>
          ) : null}

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

title,
headerShown: true,
headerBackTitle: '',
headerRight: () => (isDm ? <Circle size={12} color={otherOnline ? '#22c55e' : '#9ca3af'} fill={otherOnline ? '#22c55e' : '#9ca3af'} /> : undefined),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Duplicated hard-coded magic values for UI constants prevent theming and single-source adjustments. Extract hex colors #22c55e and #9ca3af, and icon size 12, into named constants like ONLINE_COLOR, OFFLINE_COLOR, and STATUS_ICON_SIZE.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 209:

Duplicated hard-coded magic values for UI constants prevent theming and single-source adjustments. Extract hex colors `#22c55e` and `#9ca3af`, and icon size `12`, into named constants like `ONLINE_COLOR`, `OFFLINE_COLOR`, and `STATUS_ICON_SIZE`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment on lines +59 to +61
} catch {
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Swallowed parse exception in the catch block hides operational failures by returning null without logging. Add a structured log like logger.warn('parseMetadata failed', { err }) before returning null.

Kody rule violation: Avoid empty catch blocks

Prompt for LLM

File src/components/chat/chat-utils.ts:

Line 59 to 61:

Swallowed parse exception in the catch block hides operational failures by returning `null` without logging. Add a structured log like `logger.warn('parseMetadata failed', { err })` before returning `null`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

export function parseMetadata<T>(metadataJson?: string | null): T | null {
if (!metadataJson) return null;
try {
return JSON.parse(metadataJson) as T;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Unvalidated JSON payload parsed via JSON.parse and cast with as T risks runtime errors and vulnerabilities from structural mismatches. Validate the parsed structure using a schema validator like zod or explicit field checks before returning.

Kody rule violation: Always validate JSON parsing

Prompt for LLM

File src/components/chat/chat-utils.ts:

Line 58:

Unvalidated JSON payload parsed via `JSON.parse` and cast with `as T` risks runtime errors and vulnerabilities from structural mismatches. Validate the parsed structure using a schema validator like `zod` or explicit field checks before returning.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const handleChange = (value: string) => {
setQuery(value);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => runSearch(value), 400);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Uncleared setTimeout in handleChange causes wasted network calls and a setState-after-unmount error if the sheet unmounts during debounce. Add a useEffect cleanup hook to call clearTimeout(debounceRef.current) on teardown.

Kody rule violation: Clear timers on teardown/unmount

Prompt for LLM

File src/components/chat/gif-picker-sheet.tsx:

Line 53:

Uncleared `setTimeout` in `handleChange` causes wasted network calls and a `setState`-after-unmount error if the sheet unmounts during debounce. Add a `useEffect` cleanup hook to call `clearTimeout(debounceRef.current)` on teardown.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

<Text className={textTone}>
{segments.map((segment, index) =>
segment.isLink ? (
<Text key={index} className={`underline ${isOwn ? 'text-white' : 'text-primary-600'}`} onPress={() => Linking.openURL(segment.text)}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Array index used as a React list key in message-bubble.tsx causes component reordering issues and unexpected behavior. Replace the index with a unique identifier.

Kody rule violation: Avoid array indexes as keys in React lists

Prompt for LLM

File src/components/chat/message-bubble.tsx:

Line 109:

Array index used as a React list key in `message-bubble.tsx` causes component reordering issues and unexpected behavior. Replace the index with a unique identifier.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}

if (message.MessageType === ChatMessageType.Image) {
const attachment = message.Attachments[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Unguarded array index access on message.Attachments causes unexpected failures when the array is empty. Guard the access with a length check or use a safe accessor before assigning to attachment.

Kody rule violation: Check query results before accessing indices

Prompt for LLM

File src/components/chat/message-bubble.tsx:

Line 68:

Unguarded array index access on `message.Attachments` causes unexpected failures when the array is empty. Guard the access with a length check or use a safe accessor before assigning to `attachment`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

key={emoji}
className="p-2"
onPress={() => {
setText((prev) => prev + emoji);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

String concatenation using the + operator reduces readability and risks implicit type coercion bugs. Replace it with a template literal: setText((prev) => \${prev}${emoji}`)`.

Kody rule violation: Use Template Literals Instead of String Concatenation

Prompt for LLM

File src/components/chat/message-composer.tsx:

Line 154:

String concatenation using the `+` operator reduces readability and risks implicit type coercion bugs. Replace it with a template literal: `setText((prev) => \`${prev}${emoji}\`)`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return recipients;
return recipients.filter((r) => r.Name.toLowerCase().includes(q));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Null pointer dereference occurs when calling .toLowerCase() on an unguarded r.Name property, throwing a TypeError. Use optional chaining with a fallback like (r.Name ?? '').toLowerCase().includes(q).

Kody rule violation: Add null checks before accessing properties

Prompt for LLM

File src/components/chat/new-conversation-sheet.tsx:

Line 65:

Null pointer dereference occurs when calling `.toLowerCase()` on an unguarded `r.Name` property, throwing a `TypeError`. Use optional chaining with a fallback like `(r.Name ?? '').toLowerCase().includes(q)`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

interface NewConversationSheetProps {
isOpen: boolean;
onClose: () => void;
mode: 'dm' | 'group';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Inline string literals defining the mode prop type lack a single source of truth and allow runtime typos in comparisons. Define a const record like const ConversationMode = { Dm: 'dm', Group: 'group' } as const; and derive the type from it.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File src/components/chat/new-conversation-sheet.tsx:

Line 27:

Inline string literals defining the `mode` prop type lack a single source of truth and allow runtime typos in comparisons. Define a const record like `const ConversationMode = { Dm: 'dm', Group: 'group' } as const;` and derive the type from it.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}

export interface ChatbotSessionResponse {
success: boolean;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Casing inconsistency on the success property violates team rules requiring PascalCase for response models. Rename the property to Success to match sibling properties like ChatChannelId and LastMessageSeq.

Kody rule violation: Use proper naming conventions

Prompt for LLM

File src/models/v4/chat/chatbotModels.ts:

Line 20:

Casing inconsistency on the `success` property violates team rules requiring PascalCase for response models. Rename the property to `Success` to match sibling properties like `ChatChannelId` and `LastMessageSeq`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts
}
},

reset: () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug critical

Cross-session data leak occurs because reset() never clears the MMKV-persisted outbox and is not invoked from auth.logout(), causing handleChatConnected() to trigger drainOutbox() and expose user A's private message content to user B. Add outbox: [] to the reset() state update and call useChatStore.getState().reset() from the auth store's logout() method.

reset: () => {
        typingTimers.forEach((t) => clearTimeout(t));
        typingTimers.clear();
        lastTypingSentAt.clear();
        lastMarkedSeq.clear();
        set({
          channels: [],
          messagesByChannel: {},
          membersByChannel: {},
          typingByChannel: {},
          presence: new Set<string>(),
          pendingAcks: [],
          activeChannelId: null,
          chatbotChannelId: null,
          chatbotTyping: false,
          hasMoreByChannel: {},
          loadingMessagesByChannel: {},
          outbox: [],
        });
      },
Prompt for LLM

File src/stores/chat/store.ts:

Line 726:

Cross-session data leak occurs because `reset()` never clears the MMKV-persisted outbox and is not invoked from `auth.logout()`, causing `handleChatConnected()` to trigger `drainOutbox()` and expose user A's private message content to user B. Add `outbox: []` to the `reset()` state update and call `useChatStore.getState().reset()` from the auth store's `logout()` method.

Suggested Code:

reset: () => {
        typingTimers.forEach((t) => clearTimeout(t));
        typingTimers.clear();
        lastTypingSentAt.clear();
        lastMarkedSeq.clear();
        set({
          channels: [],
          messagesByChannel: {},
          membersByChannel: {},
          typingByChannel: {},
          presence: new Set<string>(),
          pendingAcks: [],
          activeChannelId: null,
          chatbotChannelId: null,
          chatbotTyping: false,
          hasMoreByChannel: {},
          loadingMessagesByChannel: {},
          outbox: [],
        });
      },

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts Outdated
drainOutbox: async () => {
const items = [...get().outbox];
for (const item of items) {
await sendOutboxItem(item, set, get);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

N+1 API call pattern occurs because chatApi.sendMessage is triggered sequentially via sendOutboxItem inside a for-loop. Refactor to use a bulk send endpoint or Promise.allSettled to parallelize the independent network requests.

Kody rule violation: Detect N+1 style queries and suggest batching

Prompt for LLM

File src/stores/chat/store.ts:

Line 361:

N+1 API call pattern occurs because `chatApi.sendMessage` is triggered sequentially via `sendOutboxItem` inside a for-loop. Refactor to use a bulk send endpoint or `Promise.allSettled` to parallelize the independent network requests.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts
const incoming = response.Data ?? [];
set((state) => {
let list = state.messagesByChannel[channelId] ?? [];
for (const m of incoming) list = upsertMessage(list, { ...m, _localStatus: 'sent' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Duplicated upsert-and-mark-sent loop logic in loadOlderMessages and loadNewerMessages increases maintenance burden and divergence risk. Extract the loop into a shared helper function like mergeIncoming(list, incoming).

Kody rule violation: Extract duplicated logic into functions

Prompt for LLM

File src/stores/chat/store.ts:

Line 244:

Duplicated upsert-and-mark-sent loop logic in `loadOlderMessages` and `loadNewerMessages` increases maintenance burden and divergence risk. Extract the loop into a shared helper function like `mergeIncoming(list, incoming)`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts Outdated
drainOutbox: async () => {
const items = [...get().outbox];
for (const item of items) {
await sendOutboxItem(item, set, get);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Sequential await inside a for-loop in drainOutbox underutilizes concurrency and blocks subsequent items if one hangs. Replace the loop with await Promise.allSettled(items.map(item => sendOutboxItem(item, set, get))) to parallelize sends and inspect failures.

Kody rule violation: Use Promise.allSettled for batch operations with partial failures

Prompt for LLM

File src/stores/chat/store.ts:

Line 361:

Sequential await inside a for-loop in `drainOutbox` underutilizes concurrency and blocks subsequent items if one hangs. Replace the loop with `await Promise.allSettled(items.map(item => sendOutboxItem(item, set, get)))` to parallelize sends and inspect failures.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

useChatStore.getState().handleChatConnected();
};
chatHubHandlers.onChatConnected = onChatConnected;
signalRService.on('onChatConnected', onChatConnected);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Raw string literal 'onChatConnected' duplicates an entry in CHAT_HUB_METHODS, risking silent drift during refactoring. Extract 'onChatConnected' into a shared named constant or reference its element directly from CHAT_HUB_METHODS.

Kody rule violation: Centralize string constants

Prompt for LLM

File src/stores/signalr/signalr-store.ts:

Line 674:

Raw string literal `'onChatConnected'` duplicates an entry in `CHAT_HUB_METHODS`, risking silent drift during refactoring. Extract `'onChatConnected'` into a shared named constant or reference its element directly from `CHAT_HUB_METHODS`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

@Resgrid-Bot

This comment has been minimized.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
src/components/chat/chat-utils.ts (3)

78-105: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep sentence punctuation out of link targets.

URL_REGEX stops only at whitespace. A sentence such as https://example.com. produces a link segment containing the period. Parentheses and commas have the same problem. Split trailing punctuation into a plain-text segment and add regression tests.

Proposed fix
-    segments.push({ text: match[0], isLink: true });
-    lastIndex = index + match[0].length;
+    const rawUrl = match[0];
+    const url = rawUrl.replace(/[.,!?;:)\]}]+$/, '');
+    segments.push({ text: url, isLink: true });
+    if (url.length < rawUrl.length) {
+      segments.push({ text: rawUrl.slice(url.length), isLink: false });
+    }
+    lastIndex = index + rawUrl.length;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/chat-utils.ts` around lines 78 - 105, Update URL_REGEX
and linkifySegments so trailing sentence punctuation such as periods, commas,
and parentheses is excluded from link segments and emitted as plain text
instead. Preserve valid URL content and hasLink behavior, and add regression
tests covering these punctuation cases.

107-123: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a native clipboard implementation for chat copying.

copyToClipboard() only checks navigator.clipboard.writeText, then returns false. Add the repository’s native clipboard module for iOS/Android and keep the web path for web/Electron.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/chat-utils.ts` around lines 107 - 123, Update
copyToClipboard to use the repository’s native clipboard module on iOS and
Android, while preserving the existing navigator.clipboard.writeText path for
web and Electron. Return true only after either implementation succeeds, and
retain the false fallback when neither clipboard implementation is available or
an operation fails.

Source: Coding guidelines


1-40: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Add Jest coverage for the missing utility paths.

chat-utils.test.ts covers getChannelDisplayName, linkifySegments, hasLink, and getImageMimeType, but it does not cover groupChannels, copyToClipboard, or formatShortTime. Add cases for archived filtering and group ordering; for clipboard writes and invalid dates, use Jest mocks and injected date behavior so these tests remain deterministic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/chat-utils.ts` around lines 1 - 40, Add Jest coverage in
chat-utils.test.ts for groupChannels, copyToClipboard, and formatShortTime.
Verify archived channels are excluded, channel types are assigned to the correct
groups, and each group is ordered by most recent LastMessageOn; mock clipboard
writes and inject deterministic dates to cover successful clipboard behavior and
invalid-date handling without relying on real browser APIs or current time.

Source: Coding guidelines

src/stores/chat/store.ts (2)

440-447: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Give moderators failure feedback on moderatorDeleteMessage.

Every other mutating action in this file (editMessage, deleteMessage, togglePin, flagMessage) shows a toast on failure. moderatorDeleteMessage only logs the error and leaves the moderator without feedback that the delete failed. Add a toast call here to match the established pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 440 - 447, Update
moderatorDeleteMessage to display an error toast in its catch block, matching
the established failure-feedback pattern used by editMessage, deleteMessage,
togglePin, and flagMessage, while retaining the existing logger.error call.

452-496: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reaction rollback can overwrite a concurrent realtime update.

addReaction and removeReaction snapshot previousReactions before the optimistic update (Line 454, Line 478) and, on API failure, restore that exact snapshot via patchMessage(set, channelId, messageId, { Reactions: previousReactions }) (Line 471, Line 493). If a handleReactionUpdated event for the same message arrives while the chatApi.addReaction/chatApi.removeReaction call is pending, the rollback discards that newer server state and replaces it with the stale snapshot. Restore by removing only the locally-added or locally-removed reaction from the current state at rollback time, instead of replacing the whole array with the pre-optimistic snapshot.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 452 - 496, Update the rollback logic
in addReaction and removeReaction to inspect the message’s current Reactions
when the API call fails, rather than restoring previousReactions wholesale. For
addReaction, remove only the locally added user/emoji reaction; for
removeReaction, re-add only the locally removed reaction if it is still absent,
preserving any newer realtime reactions from handleReactionUpdated. Remove the
stale snapshot-based patching while keeping the existing error logging and toast
behavior.
src/components/chat/new-conversation-sheet.tsx (1)

78-91: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard against a nullable recipient.Name before calling .toLowerCase().

Line 81 calls r.Name.toLowerCase() without a null check. This was flagged in a previous review. If the recipients API can return an entry with no Name, typing in the search field throws a TypeError and crashes the new-conversation flow. Use (r.Name ?? '').toLowerCase().includes(q) here, and the same guard where recipient.Name is rendered directly (Line 180, Line 184).

#!/bin/bash
# Description: Check RecipientsResultData.Name for nullability.
rg -n -B2 -A15 'interface RecipientsResultData' src/models
🛡️ Proposed fix
-    return recipients.filter((r) => r.Name.toLowerCase().includes(q));
+    return recipients.filter((r) => (r.Name ?? '').toLowerCase().includes(q));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/new-conversation-sheet.tsx` around lines 78 - 91, Guard
nullable recipient names in the `filtered` callback by using an empty-string
fallback before `toLowerCase()`, and apply the same fallback wherever
`recipient.Name` is rendered directly in the new-conversation sheet. Preserve
the existing filtering and display behavior for non-null names.
src/services/push-notification.ts (1)

236-239: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Use the established capital eventCode prefixes before deep-linking.

handleChatDeepLink only accepts lowercase t: and g:, but the stored modal data uses uppercase T: and G: for chat. This makes chat notification responses return false and skip navigation, while non-chat codes also become no-ops. Normalize eventCode here, or update the chat helper to accept both conventions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/services/push-notification.ts` around lines 236 - 239, Update the
deep-link handling around handleChatDeepLink so stored chat event codes using
uppercase T: and G: prefixes are accepted before navigation. Normalize
data.eventCode at this call site or extend handleChatDeepLink to support both
uppercase and lowercase prefixes, while preserving no-op behavior for non-chat
event codes.
🧹 Nitpick comments (2)
src/components/chat/gif-picker-sheet.tsx (1)

94-106: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an accessibility label to each GIF selection button.

The Pressable wrapping each GIF Image (Line 95-105) has no accessibilityLabel. A screen reader user cannot distinguish between GIF options. Add a label, for example gif.Title with a fallback string.

♿ Proposed fix
                 <Pressable
                   key={gif.Id}
                   className="mb-2"
                   style={{ width: '48%' }}
+                  accessibilityLabel={gif.Title || t('chat.gif')}
                   onPress={() => {
                     onSelect(gif);
                     onClose();
                   }}
                 >

As per coding guidelines, "Ensure the app is accessible, following WCAG guidelines for mobile applications."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/gif-picker-sheet.tsx` around lines 94 - 106, Update the
GIF option Pressable in the gifs.map rendering to include an accessibilityLabel
derived from gif.Title, with a fallback string when the title is missing, so
screen readers can distinguish each selection button.

Source: Coding guidelines

src/stores/chat/store.ts (1)

300-318: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Add an iteration cap to the delta-sync loop in loadNewerMessages.

The for (;;) loop keeps calling chatApi.getMessagesAfter as long as each page returns 200 items, with no maximum iteration count and no delay between requests. A large backlog, or a server that always returns exactly 200 items due to a paging bug, turns this into a tight request loop on reconnect. Add a maximum number of pages (or a total message cap) to bound worst-case behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 300 - 318, Bound the pagination loop
in loadNewerMessages by enforcing a maximum number of pages or messages
processed, while preserving the existing early returns for empty and short
responses. Ensure the loop stops once the configured cap is reached, preventing
unbounded getMessagesAfter requests when every page contains 200 items.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/chat/chat-utils.ts`:
- Around line 1-4: Run the repository’s import-sort autofix and update the
imports in the chat utility module so the TFunction import follows the
configured simple-import-sort order alongside getAvatarUrl and the chat model
imports.

In `@src/lib/navigation.ts`:
- Around line 6-27: Validate RouterPushRetryOptions before entering the retry
loop in routerPushWithRetry: require maxAttempts to be a finite positive integer
and retryDelayMs to be finite and non-negative, rejecting invalid values
immediately. Preserve the existing defaults and retry behavior for valid
options, while ensuring maxAttempts = 0 cannot trigger a navigation attempt.

In `@src/stores/chat/store.ts`:
- Around line 393-416: Track in-flight ClientMessageId values at module scope
and have sendOutboxItem register and clear each ID with guaranteed cleanup
around the API request. Update drainOutbox to skip items whose ClientMessageId
is already in flight, while preserving existing retry and eligibility handling
so sendMessage and retryOutboxItem cannot issue duplicate sends.

In `@src/translations/de.json`:
- Line 1460: Update the chat.thread_replies translations to use i18next plural
variants: add the locale-appropriate plural keys in
src/translations/de.json:1460-1460, src/translations/es.json:1460-1460,
src/translations/fr.json:1460-1460, and src/translations/it.json:1460-1460; add
Arabic zero, one, two, few, many, and other forms in
src/translations/ar.json:1460-1460; and add few, many, and other forms in
src/translations/pl.json:1460-1460 and src/translations/uk.json:1460-1460.
Preserve the existing count interpolation and singular wording so a single reply
uses the singular form.

---

Outside diff comments:
In `@src/components/chat/chat-utils.ts`:
- Around line 78-105: Update URL_REGEX and linkifySegments so trailing sentence
punctuation such as periods, commas, and parentheses is excluded from link
segments and emitted as plain text instead. Preserve valid URL content and
hasLink behavior, and add regression tests covering these punctuation cases.
- Around line 107-123: Update copyToClipboard to use the repository’s native
clipboard module on iOS and Android, while preserving the existing
navigator.clipboard.writeText path for web and Electron. Return true only after
either implementation succeeds, and retain the false fallback when neither
clipboard implementation is available or an operation fails.
- Around line 1-40: Add Jest coverage in chat-utils.test.ts for groupChannels,
copyToClipboard, and formatShortTime. Verify archived channels are excluded,
channel types are assigned to the correct groups, and each group is ordered by
most recent LastMessageOn; mock clipboard writes and inject deterministic dates
to cover successful clipboard behavior and invalid-date handling without relying
on real browser APIs or current time.

In `@src/components/chat/new-conversation-sheet.tsx`:
- Around line 78-91: Guard nullable recipient names in the `filtered` callback
by using an empty-string fallback before `toLowerCase()`, and apply the same
fallback wherever `recipient.Name` is rendered directly in the new-conversation
sheet. Preserve the existing filtering and display behavior for non-null names.

In `@src/services/push-notification.ts`:
- Around line 236-239: Update the deep-link handling around handleChatDeepLink
so stored chat event codes using uppercase T: and G: prefixes are accepted
before navigation. Normalize data.eventCode at this call site or extend
handleChatDeepLink to support both uppercase and lowercase prefixes, while
preserving no-op behavior for non-chat event codes.

In `@src/stores/chat/store.ts`:
- Around line 440-447: Update moderatorDeleteMessage to display an error toast
in its catch block, matching the established failure-feedback pattern used by
editMessage, deleteMessage, togglePin, and flagMessage, while retaining the
existing logger.error call.
- Around line 452-496: Update the rollback logic in addReaction and
removeReaction to inspect the message’s current Reactions when the API call
fails, rather than restoring previousReactions wholesale. For addReaction,
remove only the locally added user/emoji reaction; for removeReaction, re-add
only the locally removed reaction if it is still absent, preserving any newer
realtime reactions from handleReactionUpdated. Remove the stale snapshot-based
patching while keeping the existing error logging and toast behavior.

---

Nitpick comments:
In `@src/components/chat/gif-picker-sheet.tsx`:
- Around line 94-106: Update the GIF option Pressable in the gifs.map rendering
to include an accessibilityLabel derived from gif.Title, with a fallback string
when the title is missing, so screen readers can distinguish each selection
button.

In `@src/stores/chat/store.ts`:
- Around line 300-318: Bound the pagination loop in loadNewerMessages by
enforcing a maximum number of pages or messages processed, while preserving the
existing early returns for empty and short responses. Ensure the loop stops once
the configured cap is reached, preventing unbounded getMessagesAfter requests
when every page contains 200 items.
🪄 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 Plus

Run ID: adb1b67a-8f5c-4fb7-b017-184b64cd5102

📥 Commits

Reviewing files that changed from the base of the PR and between d522abe and a9d7038.

📒 Files selected for processing (21)
  • src/api/chat/chat.ts
  • src/app/(app)/chat.tsx
  • src/app/chat/[channelId].tsx
  • src/components/chat/__tests__/chat-utils.test.ts
  • src/components/chat/chat-utils.ts
  • src/components/chat/gif-picker-sheet.tsx
  • src/components/chat/message-composer.tsx
  • src/components/chat/new-conversation-sheet.tsx
  • src/lib/navigation.ts
  • src/models/v4/chat/outbox.ts
  • src/services/push-notification.ts
  • src/stores/chat/store.ts
  • src/translations/ar.json
  • src/translations/de.json
  • src/translations/en.json
  • src/translations/es.json
  • src/translations/fr.json
  • src/translations/it.json
  • src/translations/pl.json
  • src/translations/sv.json
  • src/translations/uk.json
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/models/v4/chat/outbox.ts
  • src/translations/en.json
  • src/app/(app)/chat.tsx
  • src/app/chat/[channelId].tsx

Comment thread src/components/chat/chat-utils.ts Outdated
Comment thread src/lib/navigation.ts
Comment on lines +6 to +27
export interface RouterPushRetryOptions {
maxAttempts?: number;
retryDelayMs?: number;
}

/**
* Pushes an expo-router href, retrying when the router has not mounted yet
* (cold-start deep links). Throws the last error once every attempt fails.
*/
export const routerPushWithRetry = async (href: Href, options?: RouterPushRetryOptions): Promise<void> => {
const maxAttempts = options?.maxAttempts ?? 1;
const retryDelayMs = options?.retryDelayMs ?? 250;

for (let attempt = 1; ; attempt++) {
try {
router.push(href);
return;
} catch (error) {
if (attempt >= maxAttempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject invalid retry options before entering the retry loop.

maxAttempts is not validated. If it is NaN or Infinity, attempt >= maxAttempts never becomes true when navigation keeps failing, so the promise retries indefinitely. maxAttempts = 0 also performs one attempt. Validate a finite positive integer and a finite non-negative retryDelayMs.

Proposed guard
  const maxAttempts = options?.maxAttempts ?? 1;
  const retryDelayMs = options?.retryDelayMs ?? 250;
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
+   throw new RangeError('maxAttempts must be a positive integer');
+ }
+ if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
+   throw new RangeError('retryDelayMs must be a finite non-negative number');
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export interface RouterPushRetryOptions {
maxAttempts?: number;
retryDelayMs?: number;
}
/**
* Pushes an expo-router href, retrying when the router has not mounted yet
* (cold-start deep links). Throws the last error once every attempt fails.
*/
export const routerPushWithRetry = async (href: Href, options?: RouterPushRetryOptions): Promise<void> => {
const maxAttempts = options?.maxAttempts ?? 1;
const retryDelayMs = options?.retryDelayMs ?? 250;
for (let attempt = 1; ; attempt++) {
try {
router.push(href);
return;
} catch (error) {
if (attempt >= maxAttempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
export interface RouterPushRetryOptions {
maxAttempts?: number;
retryDelayMs?: number;
}
/**
* Pushes an expo-router href, retrying when the router has not mounted yet
* (cold-start deep links). Throws the last error once every attempt fails.
*/
export const routerPushWithRetry = async (href: Href, options?: RouterPushRetryOptions): Promise<void> => {
const maxAttempts = options?.maxAttempts ?? 1;
const retryDelayMs = options?.retryDelayMs ?? 250;
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
throw new RangeError('maxAttempts must be a positive integer');
}
if (!Number.isFinite(retryDelayMs) || retryDelayMs < 0) {
throw new RangeError('retryDelayMs must be a finite non-negative number');
}
for (let attempt = 1; ; attempt++) {
try {
router.push(href);
return;
} catch (error) {
if (attempt >= maxAttempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/navigation.ts` around lines 6 - 27, Validate RouterPushRetryOptions
before entering the retry loop in routerPushWithRetry: require maxAttempts to be
a finite positive integer and retryDelayMs to be finite and non-negative,
rejecting invalid values immediately. Preserve the existing defaults and retry
behavior for valid options, while ensuring maxAttempts = 0 cannot trigger a
navigation attempt.

Comment thread src/stores/chat/store.ts
Comment on lines +393 to +416
drainOutbox: async () => {
if (isDrainingOutbox) return;
isDrainingOutbox = true;
try {
const userId = currentUserId();
const now = Date.now();
let nextEligibleIn: number | null = null;
for (const item of [...get().outbox]) {
if (item.SenderUserId && item.SenderUserId !== userId) continue;
const attempts = item.Attempts ?? 0;
if (attempts >= MAX_OUTBOX_ATTEMPTS) continue;
const delay = outboxRetryDelayMs(attempts);
const elapsed = now - (item.LastAttemptAt ?? 0);
if (attempts > 0 && elapsed < delay) {
nextEligibleIn = Math.min(nextEligibleIn ?? Number.MAX_SAFE_INTEGER, delay - elapsed);
continue;
}
await sendOutboxItem(item, set, get);
}
if (nextEligibleIn !== null) scheduleOutboxDrain(nextEligibleIn);
} finally {
isDrainingOutbox = false;
}
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether the send-message endpoint deduplicates by ClientMessageId server-side or client-side elsewhere.
rg -n 'ClientMessageId' src/api/chat/chat.ts src/stores/chat/store.ts -B2 -A2

Repository: Resgrid/Dispatch

Length of output: 6121


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant store implementation around sendMessage, outbox insertion, sending, and retry.
sed -n '300,425p' src/stores/chat/store.ts
printf '\n--- sendOutboxItem ---\n'
sed -n '835,885p' src/stores/chat/store.ts
printf '\n--- retryOutboxItem/markOutboxFailed ---\n'
sed -n '360,385p' src/stores/chat/store.ts
sed -n '938,975p' src/stores/chat/store.ts
printf '\n--- ClientMessageId occurrences in store ---\n'
rg -n 'ClientMessageId|isDrainingOutbox|sendOutboxItem|handleChatConnected' src/stores/chat/store.ts

Repository: Resgrid/Dispatch

Length of output: 12108


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check send-message endpoint and all sendMessage usages to understand whether ClientMessageId deduplication is implemented anywhere.
rg -n 'sendMessage|ClientMessageId' src/api src -g '!**/node_modules/**' | sed -n '1,220p'

Repository: Resgrid/Dispatch

Length of output: 3918


Prevent concurrent sends for the same outbox item.

sendMessage and retryOutboxItem can call sendOutboxItem at the same time as drainOutbox, because drainOutbox only guards against one drain in progress and the item is removed from outbox only after the API call resolves. This can POST the same ClientMessageId to chatApi.sendMessage more than once per store. Track in-flight ClientMessageIds at the module level and skip an item in drainOutbox if another caller is already sending it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/stores/chat/store.ts` around lines 393 - 416, Track in-flight
ClientMessageId values at module scope and have sendOutboxItem register and
clear each ID with guaranteed cleanup around the API request. Update drainOutbox
to skip items whose ClientMessageId is already in flight, while preserving
existing retry and eligibility handling so sendMessage and retryOutboxItem
cannot issue duplicate sends.

Comment thread src/translations/de.json
"urgent": "Dringend",
"urgent_will_send": "Diese Nachricht wird als dringend gesendet",
"shared_location": "Geteilter Standort",
"thread_replies": "{{count}} Antworten",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked translation files around target key:"
for f in src/translations/de.json src/translations/ar.json src/translations/es.json src/translations/fr.json src/translations/it.json src/translations/pl.json src/translations/uk.json; do
  if [ -f "$f" ]; then
    line=$(grep -n '"thread_replies"' "$f" | head -n1 | cut -d: -f1 || true)
    echo "--- $f line $line"
    sed -n "$((line>3?line-3:1)),$((line+3))p" "$f" | cat -n
  else
    echo "MISSING $f"
  fi
done

echo
echo "Search for thread_replies usages:"
rg -n "thread_replies|\.t\(.*thread_replies|thread_replies" src -S || true

echo
echo "i18next config/key namespaces hints:"
rg -n "i18next|i18n|useTranslation|changeLanguage|savedKeys|resources|thread_replies" src -S | head -n 120 || true

Repository: Resgrid/Dispatch

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Lockfile/package references for i18next variants:"
for f in package.json pnpm-lock.yaml yarn.lock package-lock.json; do
  if [ -f "$f" ]; then
    echo "--- $f"
    rg -n "i18next|`@lingui`|plural-forms|react-i18next" "$f" || true
  fi
done

echo
echo "i18next config declarations:"
rg -n "i18next.init|init\\(|react-i18next|pluralSeparator|plurals|resStore|resources|fallbackLng|supportedLngs" src package.json -S --max-count 200 || true

echo
echo "Translation files for thread_replies variant keys (focused parse):"
python3 - <<'PY'
import json, os, re
targets = ["de","ar","es","fr","it","pl","uk","en","sv"]
key = "chat.thread_replies"
variants = [
    ("de", ["singular","plural"]),
    ("ar", ["zero","one","two","few","many","other"]),
    ("es", ["one","other"]),
    ("fr", ["one","other"]),
    ("it", ["one","other"]),
    ("pl", ["one","few","many","other"]),
    ("uk", ["one","few","many","other"]),
]
for loc in targets:
    path = f"src/translations/{loc}.json"
    if not os.path.isfile(path):
        print(f"MISSING {path}")
        continue
    data=json.load(open(path, encoding="utf-8"))
    chat=data.get("chat")
    candidates=[]
    if isinstance(chat, dict):
        for k,v in chat.items():
            if k == key or k.startswith(key+"."):
                candidates.append((k,v))
    print(f"--- {path}: {len(candidates)} candidates")
    for k,v in candidates:
        print(f"  {k}:{v!r}")
    for base, keys in variants:
        print(f"{loc} candidates: {[k for k,v in candidates if k.startswith(f'{base}.')]}")
PY

echo
echo "Component context:"
sed -n '148,174p' src/components/chat/message-bubble.tsx | cat -n

Repository: Resgrid/Dispatch

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Focused manifest dependency refs:"
sed -n '1,220p' package.json | rg -n "\"(i18next|react-i18next|i18next|packages|i18next[^"])":" package.json || true

echo
echo "i18next init/config files:"
for f in src/lib/i18n/index.tsx src/lib/i18n/resources.ts src/lib/i18n/utils.tsx src/lib/i18n/resources-*.ts; do
  if [ -f "$f" ]; then
    echo "--- $f"
    sed -n '1,160p' "$f" | cat -n
  fi
done

echo
echo "Focused translation key occurrences:"
python3 - <<'PY'
import json, pathlib, re
for f in sorted(pathlib.Path("src/translations").glob("*.json")):
    data=json.loads(f.read_text())
    chat=data.get("chat") or {}
    hits=[(k,v) for k,v in chat.items() if k == "thread_replies" or k.startswith("thread_replies.")]
    if hits:
        print("---", f)
        for k,v in hits: print(f"  {k}: {v!r}")
PY

echo
echo "Component locale/call site:"
sed -n '1,220p' src/components/chat/message-bubble.tsx | cat -n

Repository: Resgrid/Dispatch

Length of output: 241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Focused manifest dependency refs:"
python3 - <<'PY'
import json
from pathlib import Path
p=Path("package.json")
data=json.loads(p.read_text())
for section in ("dependencies","devDependencies","peerDependencies","optionalDependencies"):
    vals=data.get(section) or {}
    matches={k:v for k,v in vals.items() if "i18next" in k}
    if matches:
        print(section, matches)
PY

echo
echo "i18next init/config files:"
for f in src/lib/i18n/index.tsx src/lib/i18n/resources.ts src/lib/i18n/utils.tsx; do
  if [ -f "$f" ]; then
    echo "--- $f"
    sed -n '1,160p' "$f" | cat -n
  fi
done

echo
echo "Focused translation key occurrences:"
python3 - <<'PY'
import json
from pathlib import Path
for f in sorted(Path("src/translations").glob("*.json")):
    data=json.loads(f.read_text())
    chat=data.get("chat") or {}
    hits=[(k,v) for k,v in chat.items() if k == "thread_replies" or k.startswith("thread_replies.")]
    if hits:
        print("---", f)
        for k,v in hits:
            print(f"  {k}: {v!r}")
PY

echo
echo "Component locale/call site:"
sed -n '1,220p' src/components/chat/message-bubble.tsx | cat -n

Repository: Resgrid/Dispatch

Length of output: 15791


🌐 Web query:

i18next 23 plural keys compatibilityJSON v3 example key_one key_other

💡 Result:

In i18next versions 21 through 23, setting compatibilityJSON: 'v3' allows you to use older JSON format pluralization rules, which differ from the standard v4 format [1][2][3]. Under v3 compatibility mode, pluralization does not strictly require the _one and _other suffixes used in the modern Intl.PluralRules-based (v4) format [4][2][5]. Instead, v3 typically relies on suffixes like _plural or numerical suffixes (e.g., _0, _1, _2) depending on the specific language's plural requirements [1][6][3]. If you are currently using or migrating to a version of i18next newer than v23, please note that the compatibilityJSON: 'v3' option has been removed [1][2][5]. Since i18next v24, the Intl.PluralRules API is mandatory [5]. If your environment (such as certain React Native configurations) lacks this API, you must use a polyfill (like intl-pluralrules) rather than relying on the v3 compatibility mode [4][5]. Example of modern (v4) plural keys (the standard for v24+): { "key_one": "item", "key_other": "items" } If you are using v21-v23 and specifically want to enforce v3 behavior, you would configure it as follows [1][2]: i18next.init({ compatibilityJSON: 'v3' }); For environments requiring migration, the i18next team provides conversion tools to update existing v3-style translation files to the modern v4 format [4][7].

Citations:


Add locale-specific plural variants for chat.thread_replies.

src/components/chat/message-bubble.tsx passes count, but the affected translation files only define one plural noun form. Add the i18next plural keys needed for each locale so single-reply threads do not use the plural form.

  • German, Spanish, French, Italian: add chat.thread_replies plural forms alongside chat.thread_replies/singular.
  • Arabic: add the required Arabic count-specific forms.
  • Polish, Ukrainian: add few, many, and other forms as needed for local grammar.
📍 Affects 7 files
  • src/translations/de.json#L1460-L1460 (this comment)
  • src/translations/ar.json#L1460-L1460
  • src/translations/es.json#L1460-L1460
  • src/translations/fr.json#L1460-L1460
  • src/translations/it.json#L1460-L1460
  • src/translations/pl.json#L1460-L1460
  • src/translations/uk.json#L1460-L1460
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/translations/de.json` at line 1460, Update the chat.thread_replies
translations to use i18next plural variants: add the locale-appropriate plural
keys in src/translations/de.json:1460-1460, src/translations/es.json:1460-1460,
src/translations/fr.json:1460-1460, and src/translations/it.json:1460-1460; add
Arabic zero, one, two, few, many, and other forms in
src/translations/ar.json:1460-1460; and add few, many, and other forms in
src/translations/pl.json:1460-1460 and src/translations/uk.json:1460-1460.
Preserve the existing count interpolation and singular wording so a single reply
uses the singular form.

Comment on lines +155 to +166
const unsubscribe = useChatStore.subscribe((state) => {
const sent = (state.messagesByChannel[channelId] ?? []).find((m) => m._localAttachmentUri === uri && !m.ChatMessageId.startsWith('local-'));
if (!sent) return;
unsubscribe();
void (async () => {
try {
await uploadAttachment(channelId, sent.ChatMessageId, { uri, name, type });
} catch {
useToastStore.getState().showToast('error', t('chat.attachment_failed'));
}
})();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Subscription leak: handleSendImage creates a useChatStore.subscribe listener (line 155) that only unsubscribes on message reconciliation, so a terminal 4xx failure or exhausted MAX_OUTBOX_ATTEMPTS leaves a permanent subscription that fires an O(N) .find() over channel messages on every store update. Add a _localStatus === 'failed' check inside the callback to unsubscribe on terminal failure.

const unsubscribe = useChatStore.subscribe((state) => {
  const msg = (state.messagesByChannel[channelId] ?? []).find((m) => m._localAttachmentUri === uri);
  if (!msg) return;
  if (msg._localStatus === 'failed') { unsubscribe(); return; }
  if (msg.ChatMessageId.startsWith('local-')) return;
  unsubscribe();
  void (async () => {
    try {
      await uploadAttachment(channelId, msg.ChatMessageId, { uri, name, type });
    } catch {
      useToastStore.getState().showToast('error', t('chat.attachment_failed'));
    }
  })();
});
Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 155 to 166:

Subscription leak: `handleSendImage` creates a `useChatStore.subscribe` listener (line 155) that only unsubscribes on message reconciliation, so a terminal 4xx failure or exhausted `MAX_OUTBOX_ATTEMPTS` leaves a permanent subscription that fires an O(N) `.find()` over channel messages on every store update. Add a `_localStatus === 'failed'` check inside the callback to unsubscribe on terminal failure.

Suggested Code:

      const unsubscribe = useChatStore.subscribe((state) => {
        const msg = (state.messagesByChannel[channelId] ?? []).find((m) => m._localAttachmentUri === uri);
        if (!msg) return;
        if (msg._localStatus === 'failed') { unsubscribe(); return; }
        if (msg.ChatMessageId.startsWith('local-')) return;
        unsubscribe();
        void (async () => {
          try {
            await uploadAttachment(channelId, msg.ChatMessageId, { uri, name, type });
          } catch {
            useToastStore.getState().showToast('error', t('chat.attachment_failed'));
          }
        })();
      });

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment on lines +155 to +166
const unsubscribe = useChatStore.subscribe((state) => {
const sent = (state.messagesByChannel[channelId] ?? []).find((m) => m._localAttachmentUri === uri && !m.ChatMessageId.startsWith('local-'));
if (!sent) return;
unsubscribe();
void (async () => {
try {
await uploadAttachment(channelId, sent.ChatMessageId, { uri, name, type });
} catch {
useToastStore.getState().showToast('error', t('chat.attachment_failed'));
}
})();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Subscription leak: useChatStore.subscribe (line 155) never unsubscribes if the component unmounts before reconciliation or the message hits a terminal failure, accumulating permanent listeners that perform an O(N) .find() on every store state change. Track the subscription in a ref with useEffect cleanup, or unsubscribe when _localStatus transitions to 'failed'.

// Store unsubscribe in a ref and clean up on unmount or terminal failure:
// const subRef = useRef<(() => void) | null>(null);
// subRef.current = useChatStore.subscribe((state) => {
//   const msg = (state.messagesByChannel[channelId] ?? []).find(
//     (m) => m._localAttachmentUri === uri
//   );
//   if (!msg) return;
//   if (!msg.ChatMessageId.startsWith('local-')) {
//     subRef.current?.();
//     void uploadAttachment(channelId, msg.ChatMessageId, { uri, name, type });
//   } else if (msg._localStatus === 'failed') {
//     subRef.current?.(); // stop watching permanently-failed messages
//   }
// });
Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 155 to 166:

Subscription leak: `useChatStore.subscribe` (line 155) never unsubscribes if the component unmounts before reconciliation or the message hits a terminal failure, accumulating permanent listeners that perform an O(N) `.find()` on every store state change. Track the subscription in a ref with `useEffect` cleanup, or unsubscribe when `_localStatus` transitions to `'failed'`.

Suggested Code:

// Store unsubscribe in a ref and clean up on unmount or terminal failure:
// const subRef = useRef<(() => void) | null>(null);
// subRef.current = useChatStore.subscribe((state) => {
//   const msg = (state.messagesByChannel[channelId] ?? []).find(
//     (m) => m._localAttachmentUri === uri
//   );
//   if (!msg) return;
//   if (!msg.ChatMessageId.startsWith('local-')) {
//     subRef.current?.();
//     void uploadAttachment(channelId, msg.ChatMessageId, { uri, name, type });
//   } else if (msg._localStatus === 'failed') {
//     subRef.current?.(); // stop watching permanently-failed messages
//   }
// });

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment on lines +162 to +163
} catch {
useToastStore.getState().showToast('error', t('chat.attachment_failed'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Swallowed exception: the catch block discards the error without logging, permanently losing debug details and violating rule [28]. Capture the error parameter and log it with structured context (channelId, name, err) before showing the toast.

Kody rule violation: Avoid empty catch blocks

Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 162 to 163:

Swallowed exception: the `catch` block discards the error without logging, permanently losing debug details and violating rule [28]. Capture the error parameter and log it with structured context (`channelId`, `name`, `err`) before showing the toast.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

})();
});

void useChatStore.getState().sendMessage({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled promise rejection: void useChatStore.getState().sendMessage(...) discards rejections without a .catch() handler, violating rule [1]. Add .catch((e) => logger.error('sendMessage failed', { channelId, err: e })) or wrap the call in a try/catch inside an async IIFE.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/app/chat/[channelId].tsx:

Line 168:

Unhandled promise rejection: `void useChatStore.getState().sendMessage(...)` discards rejections without a `.catch()` handler, violating rule [1]. Add `.catch((e) => logger.error('sendMessage failed', { channelId, err: e }))` or wrap the call in a try/catch inside an async IIFE.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

abortRef.current = controller;
setLoading(true);
try {
const response = await searchGifs(q || undefined, 24, 0, controller.signal);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Magic numbers 24 and 0 inlined in the searchGifs call obscure pagination intent. Extract them to named constants such as GIFS_PER_PAGE and INITIAL_OFFSET at module or component scope.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File src/components/chat/gif-picker-sheet.tsx:

Line 39:

Magic numbers `24` and `0` inlined in the `searchGifs` call obscure pagination intent. Extract them to named constants such as `GIFS_PER_PAGE` and `INITIAL_OFFSET` at module or component scope.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

<Pressable className="p-2" onPress={handleShareLocation} disabled={disabled} accessibilityLabel={t('chat.share_location')}>
<MapPin size={22} color="#6b7280" />
</Pressable>
<Pressable className="p-2" onPress={() => setUrgent((prev) => !prev)} disabled={disabled} accessibilityLabel={t('chat.urgent')}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Inline arrow function in JSX prop creates a new function on every render, impacting performance. Extract the setUrgent toggle into a useCallback-wrapped handler outside the JSX.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File src/components/chat/message-composer.tsx:

Line 142:

Inline arrow function in JSX prop creates a new function on every render, impacting performance. Extract the `setUrgent` toggle into a `useCallback`-wrapped handler outside the JSX.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/lib/navigation.ts
if (attempt >= maxAttempts) {
throw error;
}
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Uncancellable setTimeout in the retry loop may fire navigation after the caller unmounts or abandons the promise. Store the timer ID and provide a cancellation mechanism (e.g., AbortSignal) that calls clearTimeout(id).

Kody rule violation: Clear timers on teardown/unmount

Prompt for LLM

File src/lib/navigation.ts:

Line 27:

Uncancellable `setTimeout` in the retry loop may fire navigation after the caller unmounts or abandons the promise. Store the timer ID and provide a cancellation mechanism (e.g., AbortSignal) that calls `clearTimeout(id)`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/lib/navigation.ts
Comment on lines +11 to +15
/**
* Pushes an expo-router href, retrying when the router has not mounted yet
* (cold-start deep links). Throws the last error once every attempt fails.
*/
export const routerPushWithRetry = async (href: Href, options?: RouterPushRetryOptions): Promise<void> => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Missing @returns and @throws JSDoc tags on routerPushWithRetry omit the resolution type and rejection conditions callers need for safe await usage. Add @returns {Promise<void>} and @throws {Error} When all retry attempts fail to the JSDoc block.

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/lib/navigation.ts:

Line 11 to 15:

Missing `@returns` and `@throws` JSDoc tags on `routerPushWithRetry` omit the resolution type and rejection conditions callers need for safe await usage. Add `@returns {Promise<void>}` and `@throws {Error} When all retry attempts fail` to the JSDoc block.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts
Comment on lines +953 to +961
if (itemIdx >= 0) {
const attempts = (s.outbox[itemIdx].Attempts ?? 0) + 1;
if (terminal || attempts >= MAX_OUTBOX_ATTEMPTS) {
outbox = s.outbox.filter((o) => o.ClientMessageId !== clientMessageId);
} else {
outbox = s.outbox.slice();
outbox[itemIdx] = { ...outbox[itemIdx], Attempts: attempts, LastAttemptAt: Date.now() };
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Dead retry path: markOutboxFailed increments Attempts and sets LastAttemptAt but never calls scheduleOutboxDrain, leaving items that fail during the initial sendMessage path (line 368) with backoff metadata and no active timer. Call scheduleOutboxDrain(outboxRetryDelayMs(attempts)) in the non-terminal, under-max-attempts branch after incrementing attempts.

  } else {
    outbox = s.outbox.slice();
    outbox[itemIdx] = { ...outbox[itemIdx], Attempts: attempts, LastAttemptAt: Date.now() };
  }
}
// Schedule retry for non-terminal failures that remain in the outbox.
// Must be outside the set() callback since scheduleOutboxDrain uses setTimeout.
// (Add after the set() call: if the item is still in outbox and non-terminal,
//  call scheduleOutboxDrain(outboxRetryDelayMs(newAttempts)))
Prompt for LLM

File src/stores/chat/store.ts:

Line 953 to 961:

Dead retry path: `markOutboxFailed` increments `Attempts` and sets `LastAttemptAt` but never calls `scheduleOutboxDrain`, leaving items that fail during the initial `sendMessage` path (line 368) with backoff metadata and no active timer. Call `scheduleOutboxDrain(outboxRetryDelayMs(attempts))` in the non-terminal, under-max-attempts branch after incrementing attempts.

Suggested Code:

      } else {
        outbox = s.outbox.slice();
        outbox[itemIdx] = { ...outbox[itemIdx], Attempts: attempts, LastAttemptAt: Date.now() };
      }
    }
    // Schedule retry for non-terminal failures that remain in the outbox.
    // Must be outside the set() callback since scheduleOutboxDrain uses setTimeout.
    // (Add after the set() call: if the item is still in outbox and non-terminal,
    //  call scheduleOutboxDrain(outboxRetryDelayMs(newAttempts)))

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts
Comment on lines +302 to +314
let after = highestRealSeq(get().messagesByChannel[channelId]);
for (;;) {
const response = await chatApi.getMessagesAfter(channelId, after, 200);
const incoming = response.Data ?? [];
if (incoming.length === 0) return;
set((s) => {
let list = s.messagesByChannel[channelId] ?? [];
for (const m of incoming) list = upsertMessage(list, { ...m, _localStatus: 'sent' });
return { messagesByChannel: { ...s.messagesByChannel, [channelId]: list } };
});
if (incoming.length < 200) return;
after = highestRealSeq(get().messagesByChannel[channelId]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Performance high

Infinite loop risk: loadNewerMessages uses an unbounded for (;;) loop (line 303) that re-fetches the same 200-message batch indefinitely if the server returns without advancing the seq cursor, triggering full FlatList re-renders on every reconnect via handleChatConnected (line 793). Add a maximum iteration count (e.g., 50 batches) as a safety bound.

let after = highestRealSeq(get().messagesByChannel[channelId]);
  for (let batch = 0; batch < 50; batch++) {
    const response = await chatApi.getMessagesAfter(channelId, after, 200);
    const incoming = response.Data ?? [];
    if (incoming.length === 0) return;
Prompt for LLM

File src/stores/chat/store.ts:

Line 302 to 314:

Infinite loop risk: `loadNewerMessages` uses an unbounded `for (;;)` loop (line 303) that re-fetches the same 200-message batch indefinitely if the server returns without advancing the seq cursor, triggering full FlatList re-renders on every reconnect via `handleChatConnected` (line 793). Add a maximum iteration count (e.g., 50 batches) as a safety bound.

Suggested Code:

        let after = highestRealSeq(get().messagesByChannel[channelId]);
          for (let batch = 0; batch < 50; batch++) {
            const response = await chatApi.getMessagesAfter(channelId, after, 200);
            const incoming = response.Data ?? [];
            if (incoming.length === 0) return;

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts
}
},

drainOutbox: async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Bug high

Missing retry scheduling: outbox items that fail their first send attempt via sendMessage (line 368) or drainOutbox (line 410) sit permanently marked 'failed' because scheduleOutboxDrain is only invoked for items already in backoff at drainOutbox start (line 406). After sendOutboxItem fails, re-check whether the item remains in the outbox with Attempts > 0 and schedule a drain for its backoff delay.

if (attempts > 0 && elapsed < delay) {
    nextEligibleIn = Math.min(nextEligibleIn ?? Number.MAX_SAFE_INTEGER, delay - elapsed);
    continue;
  }
  await sendOutboxItem(item, set, get);
  // After a failure, re-check whether the item is now in backoff and needs scheduling.
  const after = get().outbox.find((o) => o.ClientMessageId === item.ClientMessageId);
  if (after && (after.Attempts ?? 0) > 0) {
    const remaining = outboxRetryDelayMs(after.Attempts ?? 0);
    nextEligibleIn = Math.min(nextEligibleIn ?? Number.MAX_SAFE_INTEGER, remaining);
  }
}
if (nextEligibleIn !== null) scheduleOutboxDrain(nextEligibleIn);
Prompt for LLM

File src/stores/chat/store.ts:

Line 393:

Missing retry scheduling: outbox items that fail their first send attempt via `sendMessage` (line 368) or `drainOutbox` (line 410) sit permanently marked 'failed' because `scheduleOutboxDrain` is only invoked for items already in backoff at `drainOutbox` start (line 406). After `sendOutboxItem` fails, re-check whether the item remains in the outbox with `Attempts > 0` and schedule a drain for its backoff delay.

Suggested Code:

          if (attempts > 0 && elapsed < delay) {
              nextEligibleIn = Math.min(nextEligibleIn ?? Number.MAX_SAFE_INTEGER, delay - elapsed);
              continue;
            }
            await sendOutboxItem(item, set, get);
            // After a failure, re-check whether the item is now in backoff and needs scheduling.
            const after = get().outbox.find((o) => o.ClientMessageId === item.ClientMessageId);
            if (after && (after.Attempts ?? 0) > 0) {
              const remaining = outboxRetryDelayMs(after.Attempts ?? 0);
              nextEligibleIn = Math.min(nextEligibleIn ?? Number.MAX_SAFE_INTEGER, remaining);
            }
          }
          if (nextEligibleIn !== null) scheduleOutboxDrain(nextEligibleIn);

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts
Comment on lines +302 to +314
let after = highestRealSeq(get().messagesByChannel[channelId]);
for (;;) {
const response = await chatApi.getMessagesAfter(channelId, after, 200);
const incoming = response.Data ?? [];
if (incoming.length === 0) return;
set((s) => {
let list = s.messagesByChannel[channelId] ?? [];
for (const m of incoming) list = upsertMessage(list, { ...m, _localStatus: 'sent' });
return { messagesByChannel: { ...s.messagesByChannel, [channelId]: list } };
});
if (incoming.length < 200) return;
after = highestRealSeq(get().messagesByChannel[channelId]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Performance high

Unbounded fetch loop: loadNewerMessages (line 303) issues sequential blocking HTTP calls on every SignalR reconnect via handleChatConnected (line 793), holding the JS thread with no cancellation path for backlog-heavy channels. Add a maximum iteration cap and/or an AbortSignal.

// Add a max iteration cap (e.g., 10 pages = 2000 messages) and/or an AbortSignal:
// const MAX_PAGES = 10;
// for (let page = 0; page < MAX_PAGES; page++) { ... }
Prompt for LLM

File src/stores/chat/store.ts:

Line 302 to 314:

Unbounded fetch loop: `loadNewerMessages` (line 303) issues sequential blocking HTTP calls on every SignalR reconnect via `handleChatConnected` (line 793), holding the JS thread with no cancellation path for backlog-heavy channels. Add a maximum iteration cap and/or an AbortSignal.

Suggested Code:

// Add a max iteration cap (e.g., 10 pages = 2000 messages) and/or an AbortSignal:
// const MAX_PAGES = 10;
// for (let page = 0; page < MAX_PAGES; page++) { ... }

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Comment thread src/stores/chat/store.ts
pendingChatbotMessages.delete(clientMessageId);
patchMessage(set, pending.channelId, `local-${clientMessageId}`, { _localStatus: 'sent' });
} catch (error) {
logger.error({ message: 'chat: chatbot resend failed', context: { error } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Incomplete error context: the chatbot resend failure log omits clientMessageId and channelId, violating rule [3] which requires the operation name and relevant identifiers as structured fields. Add both fields to the context object alongside error.

Kody rule violation: Include error context in structured logs

Prompt for LLM

File src/stores/chat/store.ts:

Line 386:

Incomplete error context: the chatbot resend failure log omits `clientMessageId` and `channelId`, violating rule [3] which requires the operation name and relevant identifiers as structured fields. Add both fields to the context object alongside `error`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

@Resgrid-Bot

Resgrid-Bot commented Aug 2, 2026

Copy link
Copy Markdown

Code Review Completed! 🔥

The code review was successfully completed based on your current configurations.

Kody Guide: Usage and Configuration
Interacting with Kody
  • Request a Review: Ask Kody to review your PR manually by adding a comment with the @kody start-review command at the root of your PR.

  • Validate Business Logic: Ask Kody to validate your code against business rules by adding a comment with the @kody -v business-logic command.

  • Provide Feedback: Help Kody learn and improve by reacting to its comments with a 👍 for helpful suggestions or a 👎 if improvements are needed.

Current Kody Configuration
Review Options

The following review options are enabled or disabled:

Options Enabled
Bug
Performance
Security
Business Logic

Access your configuration settings here.

Comment thread src/app/(app)/chat.tsx

<AckBanner acks={pendingAcks} onAcknowledge={(messageId) => useChatStore.getState().acknowledgeMessage(messageId)} />

<ScrollView className="flex-1" refreshControl={<RefreshControl refreshing={isLoading} onRefresh={() => useChatStore.getState().fetchChannels()} />}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Performance degradation results from using inline arrow functions in JSX props, generating new function allocations on every render. Move these function definitions outside the render method in src/app/(app)/chat.tsx, src/app/(app)/chatbot.tsx:80-80, src/app/chat/[channelId].tsx:271-271, src/app/chat/thread/[messageId].tsx:115-115, and src/components/chat/new-conversation-sheet.tsx:171-171.

Kody rule violation: Avoid using .bind() or arrow functions in JSX props

Prompt for LLM

File src/app/(app)/chat.tsx:

Line 124:

Performance degradation results from using inline arrow functions in JSX props, generating new function allocations on every render. Move these function definitions outside the render method in `src/app/(app)/chat.tsx`, `src/app/(app)/chatbot.tsx:80-80`, `src/app/chat/[channelId].tsx:271-271`, `src/app/chat/thread/[messageId].tsx:115-115`, and `src/components/chat/new-conversation-sheet.tsx:171-171`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const userId = recipientUserId(recipient);
const isSelected = selected.has(userId);
return (
<Pressable key={recipient.Id} className="py-2" onPress={() => (mode === 'dm' ? startDirectMessage(recipient) : toggle(userId))} disabled={submitting}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Magic string vulnerability detected where the literal 'dm' evaluates against the mode variable, risking silent bugs from typos. Extract conversation modes into a const tuple (e.g., const ConversationMode = { Dm: 'dm', Group: 'group' } as const) and compare against ConversationMode.Dm.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File src/components/chat/new-conversation-sheet.tsx:

Line 171:

Magic string vulnerability detected where the literal `'dm'` evaluates against the `mode` variable, risking silent bugs from typos. Extract conversation modes into a const tuple (e.g., `const ConversationMode = { Dm: 'dm', Group: 'group' } as const`) and compare against `ConversationMode.Dm`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants