Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ jest.mock('lucide-react-native', () => ({
const { View } = require('react-native');
return <View testID="phone-icon" />;
},
// Icons used by the incident chat section rendered inside the panel.
MessageCircle: () => {
const { View } = require('react-native');
return <View testID="message-icon" />;
},
MessagesSquare: () => {
const { View } = require('react-native');
return <View testID="messages-icon" />;
},
ShieldCheck: () => {
const { View } = require('react-native');
return <View testID="shield-icon" />;
},
Users: () => {
const { View } = require('react-native');
return <View testID="users-icon" />;
},
Comment on lines +29 to +45

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

file="src/components/incident-command/__tests__/incident-command-tab-panel.test.tsx"
printf '%s\n' '--- file outline ---'
ast-grep outline "$file" || true
printf '%s\n' '--- test file ---'
cat -n "$file"
printf '%s\n' '--- related files ---'
fd -i 'incident-command' src | sort
printf '%s\n' '--- relevant symbols and test identifiers ---'
rg -n "authorized|frozen|UserId|message|IncidentCommandTabPanel|incident chat|channel" src/components/incident-command src 2>/dev/null | head -300

Repository: Resgrid/Unit

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- tab panel chat integration ---'
sed -n '1,340p' src/components/incident-command/incident-command-tab-panel.tsx
printf '%s\n' '--- incident chat section ---'
cat -n src/components/incident-command/incident-chat-section.tsx
printf '%s\n' '--- incident chat models ---'
cat -n src/models/v4/chat/chatModels.ts
printf '%s\n' '--- incident view chat-related model fields ---'
rg -n -C 8 "Chat|Frozen|Contact|Role|ChannelId|IncidentChat" src/models/v4/incidentCommand src/components/incident-command
printf '%s\n' '--- direct message hook ---'
cat -n src/hooks/use-direct-message.ts

Repository: Resgrid/Unit

Length of output: 50368


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

impl = Path("src/components/incident-command/incident-chat-section.tsx").read_text()
test = Path("src/components/incident-command/__tests__/incident-command-tab-panel.test.tsx").read_text()

channel_ids = re.findall(r"channelId=\{chat\?\.(\w+)\}", impl)
channel_test_ids = re.findall(r'testID=\`\$\{testID\}-(\w+)\`', impl)
branch_markers = {
    "authorized_channel_visibility": "if (!channelId)" in impl and "channelId={chat?.IncidentChannelId}" in impl,
    "frozen_banner": "chat?.IsFrozen" in impl and "${testID}-frozen" in impl,
    "message_requires_user_id": "{contact.UserId ?" in impl and "${testID}-message" in impl,
}
mocked_icons = set(re.findall(r"^\s{2}(\w+):\s*\(\)", test, re.MULTILINE))
used_icons = set(re.findall(r"icon=\{(\w+)\}", impl))

print("channel fields:", channel_ids)
print("channel test-id suffixes:", channel_test_ids)
print("branch markers:", branch_markers)
print("incident-chat test references:", sorted(set(re.findall(r"incident-command-chat(?:-\w+)*", test))))
print("used chat icons:", sorted(used_icons))
print("mocked icons:", sorted(mocked_icons))
print("unmocked used icons:", sorted(used_icons - mocked_icons))
PY

Repository: Resgrid/Unit

Length of output: 675


Add incident-chat branch coverage.

Add tests for server-provided channel visibility, the IsFrozen banner, and message-button rendering for contacts with and without UserId. Add the missing Radio mock before testing dispatch-channel visibility.

🤖 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/incident-command/__tests__/incident-command-tab-panel.test.tsx`
around lines 29 - 45, Add incident-chat branch coverage in the incident command
tab panel tests: mock the missing Radio icon before dispatch-channel visibility
tests, then cover server-provided channel visibility, rendering the IsFrozen
banner, and message-button behavior for contacts both with and without UserId.

Source: Coding guidelines

}));

jest.mock('@/lib/logging', () => ({
Expand Down
139 changes: 139 additions & 0 deletions src/components/incident-command/incident-chat-section.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { router } from 'expo-router';
import { MessageCircle, MessagesSquare, Radio, ShieldCheck, Users } from 'lucide-react-native';
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';

import { Box } from '@/components/ui/box';
import { Heading } from '@/components/ui/heading';
import { HStack } from '@/components/ui/hstack';
import { Pressable } from '@/components/ui/pressable';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useDirectMessage } from '@/hooks/use-direct-message';
import { type IncidentContactInfo, type ResourceIncidentView } from '@/models/v4/incidentCommand/resourceIncidentView';

import { getIncidentRoleName } from './incident-role-names';

interface IncidentChatSectionProps {
view: ResourceIncidentView;
testID?: string;
}

/** One tappable channel row. Rendered only when the server handed us an id we are allowed to open. */
const ChannelRow: React.FC<{ label: string; hint?: string | null; icon: React.ElementType; channelId?: string | null; testID: string }> = ({ label, hint, icon: IconComponent, channelId, testID }) => {
const onPress = useCallback(() => {
if (channelId) {
router.push(`/chat/${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 Kody Rules low

Inline route path /chat/${channelId} should be centralized for consistency and single-point maintenance. Define a route constant (e.g., ROUTES.CHAT_CHANNEL = (id: string) => \/chat/${id}``) in a central routes module and reference it here.

Kody rule violation: Centralize string constants

Prompt for LLM

File src/components/incident-command/incident-chat-section.tsx:

Line 26:

Inline route path `/chat/${channelId}` should be centralized for consistency and single-point maintenance. Define a route constant (e.g., `ROUTES.CHAT_CHANNEL = (id: string) => \`/chat/${id}\``) in a central routes module and reference it here.

Talk to Kody by mentioning @kody

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

}
}, [channelId]);

if (!channelId) {
return null;
}

return (
<Pressable onPress={onPress} className="rounded-lg border border-neutral-200 p-3 dark:border-neutral-700" testID={testID}>
<HStack className="items-center" space="sm">
<IconComponent size={18} color="#3b82f6" />
<VStack className="min-w-0 flex-1">
<Text className="font-medium">{label}</Text>
{hint ? <Text className="text-xs text-gray-500">{hint}</Text> : null}
</VStack>
</HStack>
</Pressable>
);
};

/** A person on the incident with a button to open a 1:1 with them. */
const ContactRow: React.FC<{ label: string; contact?: IncidentContactInfo | null; onMessage: (userId?: string | null) => void; testID: string }> = ({ label, contact, onMessage, testID }) => {
const { t } = useTranslation();
const handlePress = useCallback(() => onMessage(contact?.UserId), [onMessage, contact]);

if (!contact) {
return null;
}

return (
<HStack className="items-center justify-between py-1" testID={testID}>
<VStack className="min-w-0 flex-1">
<Text className="text-xs text-gray-500">{label}</Text>
<Text className="font-medium">{contact.Name}</Text>
</VStack>
{/* External contacts have a name and phone but no Resgrid account, so no 1:1 to open. */}
{contact.UserId ? (
<Pressable onPress={handlePress} className="p-3" hitSlop={8} accessibilityLabel={t('incident_command.message_person', { name: contact.Name })} testID={`${testID}-message`}>

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 numeric literal hitSlop={8} obscures intent and prevents reuse across touch targets. Extract it to a named constant such as DEFAULT_HIT_SLOP = 8.

Kody rule violation: Replace magic numbers with named constants

Prompt for LLM

File src/components/incident-command/incident-chat-section.tsx:

Line 64:

Inline numeric literal `hitSlop={8}` obscures intent and prevents reuse across touch targets. Extract it to a named constant such as `DEFAULT_HIT_SLOP = 8`.

Talk to Kody by mentioning @kody

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

<MessageCircle size={20} color="#3b82f6" />
</Pressable>
) : null}
</HStack>
);
};

/**
* Incident chat on the Command tab: the channels this responder can open, and the people on the
* incident they can reach 1:1.
*
* Access is decided entirely by the server — a channel id only appears in the payload when the
* caller is actually allowed in, and it disappears again when they are (for example) taken off the
* lane. Nothing here infers access on its own.
*/
export const IncidentChatSection: React.FC<IncidentChatSectionProps> = ({ view, testID = 'incident-command-chat' }) => {
const { t } = useTranslation();
const { openDirectMessage } = useDirectMessage();

const chat = view.Chat;
const assignment = view.MyAssignment;
const roles = view.Roles ?? [];

const handleMessage = useCallback((userId?: string | null) => void openDirectMessage(userId), [openDirectMessage]);

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 rejection risk: void openDirectMessage(userId) suppresses the returned Promise, leaving potential rejections unhandled. Replace with await inside try/catch or chain .catch() to surface errors via the logger.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/components/incident-command/incident-chat-section.tsx:

Line 88:

Unhandled rejection risk: `void openDirectMessage(userId)` suppresses the returned Promise, leaving potential rejections unhandled. Replace with `await` inside try/catch or chain `.catch()` to surface errors via the logger.

Talk to Kody by mentioning @kody

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


const hasChannels = !!(chat?.IncidentChannelId || chat?.LaneChannelId || chat?.CommandChannelId || chat?.LeadsChannelId || chat?.DispatchChannelId);
// Only ICS role holders live here — the commander and this resource's lane leads already have
// contact cards higher up the panel, and listing them twice just made the screen noisy.
const hasContacts = roles.length > 0;

if (!hasChannels && !hasContacts) {
return null;
}
Comment on lines +93 to +97

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 medium

Orphaned UI element: hasContacts evaluates true when roles contains entries with a null Contact, causing the 'ICS Positions' header to render with zero rows beneath it. Derive hasContacts from roles.some((role) => !!role.Contact) so the header only renders when at least one role has a valid Contact.

const hasContacts = roles.some((role) => !!role.Contact);

  if (!hasChannels && !hasContacts) {
    return null;
  }
Prompt for LLM

File src/components/incident-command/incident-chat-section.tsx:

Line 93 to 97:

Orphaned UI element: `hasContacts` evaluates true when `roles` contains entries with a null `Contact`, causing the 'ICS Positions' header to render with zero rows beneath it. Derive `hasContacts` from `roles.some((role) => !!role.Contact)` so the header only renders when at least one role has a valid `Contact`.

Suggested Code:

const hasContacts = roles.some((role) => !!role.Contact);

  if (!hasChannels && !hasContacts) {
    return null;
  }

Talk to Kody by mentioning @kody

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


return (
<Box testID={testID}>
<Heading size="sm" className="mb-2">
{t('incident_command.chat')}
</Heading>

{chat?.IsFrozen ? (
<Box className="mb-2 rounded-lg bg-neutral-100 p-2 dark:bg-neutral-800" testID={`${testID}-frozen`}>
<Text className="text-xs text-gray-600 dark:text-gray-300">{t('incident_command.chat_frozen')}</Text>
</Box>
) : null}

{hasChannels ? (
<VStack space="sm">
<ChannelRow label={t('incident_command.incident_channel')} icon={MessagesSquare} channelId={chat?.IncidentChannelId} testID={`${testID}-incident`} />
<ChannelRow label={t('incident_command.lane_channel')} hint={assignment?.LaneName} icon={MessagesSquare} channelId={chat?.LaneChannelId} testID={`${testID}-lane`} />
<ChannelRow label={t('incident_command.command_channel')} hint={t('incident_command.command_channel_hint')} icon={ShieldCheck} channelId={chat?.CommandChannelId} testID={`${testID}-command`} />
<ChannelRow label={t('incident_command.leads_channel')} hint={t('incident_command.leads_channel_hint')} icon={Users} channelId={chat?.LeadsChannelId} testID={`${testID}-leads`} />
<ChannelRow label={t('incident_command.dispatch_channel')} hint={t('incident_command.dispatch_channel_hint')} icon={Radio} channelId={chat?.DispatchChannelId} testID={`${testID}-dispatch`} />
</VStack>
) : null}

{hasContacts ? (
<Box className="mt-3">
<Text className="mb-1 text-xs font-semibold uppercase text-gray-500">{t('incident_command.ics_positions')}</Text>
{roles.map((role) => (
<ContactRow
key={`${role.RoleType}-${role.Contact?.UserId ?? ''}`}
label={getIncidentRoleName(t, role.RoleType)}
contact={role.Contact}
onMessage={handleMessage}
testID={`${testID}-contact-role-${role.RoleType}`}
/>
))}
</Box>
) : null}
</Box>
);
};

export default IncidentChatSection;

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 clarity and refactoring ergonomics. Remove the default export and import the named export IncidentChatSection directly where needed.

Kody rule violation: Avoid default exports

Prompt for LLM

File src/components/incident-command/incident-chat-section.tsx:

Line 139:

Default export reduces clarity and refactoring ergonomics. Remove the default export and import the named export `IncidentChatSection` directly where needed.

Talk to Kody by mentioning @kody

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

20 changes: 19 additions & 1 deletion src/components/incident-command/incident-command-tab-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { format } from 'date-fns';
import { MailIcon, PhoneIcon } from 'lucide-react-native';
import { MailIcon, MessageCircle, PhoneIcon } from 'lucide-react-native';
import { useColorScheme } from 'nativewind';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -13,10 +13,13 @@ import { Pressable } from '@/components/ui/pressable';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { useDirectMessage } from '@/hooks/use-direct-message';
import { logger } from '@/lib/logging';
import { type IncidentContactInfo, IncidentNeedStatus, type TacticalObjective, TacticalObjectiveStatus } from '@/models/v4/incidentCommand/resourceIncidentView';
import { useIncidentCommandStore } from '@/stores/calls/incident-command-store';

import { IncidentChatSection } from './incident-chat-section';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the configured path alias.

Line 21 adds a relative import for IncidentChatSection. Use the project alias instead.

Proposed fix
-import { IncidentChatSection } from './incident-chat-section';
+import { IncidentChatSection } from '`@/components/incident-command/incident-chat-section`';

As per coding guidelines, source files must use path aliases from tsconfig.json instead of relative paths.

📝 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
import { IncidentChatSection } from './incident-chat-section';
import { IncidentChatSection } from '`@/components/incident-command/incident-chat-section`';
🤖 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/incident-command/incident-command-tab-panel.tsx` at line 21,
Update the IncidentChatSection import in the incident command tab panel to use
the project-configured tsconfig path alias instead of a relative path,
preserving the existing symbol and behavior.

Source: Coding guidelines


interface IncidentCommandTabPanelProps {
callId: string;
}
Expand Down Expand Up @@ -102,6 +105,9 @@ interface ContactRowProps {
}

const ContactRow: React.FC<ContactRowProps> = ({ label, contact, testID }) => {
const { t } = useTranslation();
const { openDirectMessage } = useDirectMessage();

return (
<Box className="border-b border-outline-100 pb-2" testID={testID}>
<Text className="text-sm text-gray-500">{label}</Text>
Expand All @@ -122,6 +128,15 @@ const ContactRow: React.FC<ContactRowProps> = ({ label, contact, testID }) => {
</HStack>
</Pressable>
) : null}
{/* External contacts carry a name and phone but no Resgrid account, so there is nobody to message. */}
{contact.UserId ? (
<Pressable onPress={() => void openDirectMessage(contact.UserId)} testID={`${testID}-message`}>

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. Move the function definition outside the render method or wrap it with useCallback to avoid unnecessary re-renders.

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

Prompt for LLM

File src/components/incident-command/incident-command-tab-panel.tsx:

Line 133:

Inline arrow function in the `onPress` JSX prop creates a new function on every render. Move the function definition outside the render method or wrap it with `useCallback` to avoid unnecessary re-renders.

Talk to Kody by mentioning @kody

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

<HStack className="mt-1 items-center">
<MessageCircle size={14} color="#3B82F6" />

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use a semantic theme color for the new icon.

Line 135 introduces hardcoded #3B82F6. Resolve the icon color from a semantic theme token so the action supports the light and dark palettes and the project contrast rules.

As per coding guidelines, use semantic color tokens instead of hardcoded hex values and support both light and dark mode.

🤖 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/incident-command/incident-command-tab-panel.tsx` at line 135,
Update the MessageCircle icon in the incident command tab panel to use the
project’s semantic theme color token instead of hardcoded `#3B82F6`, resolving it
through the existing light/dark theme mechanism while preserving the icon’s
current appearance and sizing.

Source: Coding guidelines

<Text className="ml-1 text-sm text-blue-500">{t('incident_command.send_message')}</Text>
</HStack>
</Pressable>
) : null}
</Box>
);
};
Expand Down Expand Up @@ -264,6 +279,9 @@ export const IncidentCommandTabPanel: React.FC<IncidentCommandTabPanelProps> = (
</VStack>
</Box>

{/* Incident chat: the channels this responder may open, plus who they can reach 1:1. */}
<IncidentChatSection view={view} />

{/* Objectives */}
<Box className={`rounded-lg p-4 shadow-xs ${cardClass}`} testID="incident-command-objectives">
<Heading size="sm">{t('incident_command.objectives')}</Heading>
Expand Down
45 changes: 45 additions & 0 deletions src/components/incident-command/incident-role-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type TFunction } from 'i18next';

/**
* Standard NIMS/ICS position titles, keyed by the Core `IncidentRoleType` value.
*
* English titles rather than translation keys: these are the position names an IC is trained
* against, and the IC app treats them the same way. Translating them needs a subject-matter
* translator per locale, not a literal one.
*/
const ICS_ROLE_NAMES: Record<number, string> = {
0: 'Incident Commander',
1: 'Deputy Incident Commander',
2: 'Unified Command Member',
3: 'Operations Section Chief',
4: 'Planning Section Chief',
5: 'Logistics Section Chief',
6: 'Finance/Admin Section Chief',
7: 'Safety Officer',
8: 'Liaison Officer',
9: 'Public Information Officer',
10: 'Staging Area Manager',
11: 'Resources Unit Leader',
12: 'Situation Unit Leader',
13: 'Documentation Unit Leader',
14: 'Communications Unit Leader',
15: 'Division/Group Supervisor',
16: 'Branch Director',
17: 'Strike Team/Task Force Leader',
18: 'Medical Unit Leader',
19: 'Rehab Officer',
20: 'Medical Branch Director',
21: 'Triage Officer',
22: 'Treatment Officer',
23: 'Transport Officer',
24: 'HazMat Group Supervisor',
25: 'Decon Officer',
26: 'Entry Team Leader',
27: 'Search Group Supervisor',
28: 'Air Operations Branch Director',
29: 'Shelter/Mass Care Coordinator',
30: 'Damage Assessment Lead',
};

/** Display name for an ICS position; falls back to a generic label for a value we don't know yet. */
export const getIncidentRoleName = (t: TFunction, roleType: number): string => ICS_ROLE_NAMES[roleType] ?? t('incident_command.role_generic', { role: roleType });
Comment on lines +10 to +45

Copy link
Copy Markdown

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

Localize the ICS role names.

getIncidentRoleName returns these strings as user-visible labels. Non-English users therefore receive English role titles. Map each role type to a translation key, then add that key to every locale. If a locale uses the standard English title, set that title explicitly in that locale.

As per coding guidelines, all user-visible text must be wrapped in t() from react-i18next.

🤖 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/incident-command/incident-role-names.ts` around lines 10 - 45,
The ICS_ROLE_NAMES values are user-visible English strings and must be
localized. Replace each role title with its corresponding translation key,
update getIncidentRoleName to resolve known keys through the provided TFunction
t, and add every key to all locale translation files, explicitly supplying the
standard English title where applicable; preserve the existing generic fallback
for unknown role types.

Source: Coding guidelines

48 changes: 48 additions & 0 deletions src/hooks/use-direct-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { router } from 'expo-router';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { createDirectMessage } from '@/api/chat/chat';
import { logger } from '@/lib/logging';
import { useToastStore } from '@/stores/toast/store';

/**
* Opens a 1:1 conversation with someone and navigates to it.
*
* The server dedups on a normalized participant key, so calling this repeatedly for the same person
* reuses the existing conversation rather than starting a new one — which is what makes it safe to
* hang a "message" button off every contact on an incident.
*/
export const useDirectMessage = () => {
const { t } = useTranslation();
const [isOpening, setIsOpening] = useState(false);

const openDirectMessage = useCallback(
async (targetUserId?: string | null) => {
Comment on lines +20 to +21

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: async function openDirectMessage returns a Promise but lacks documentation for its resolve value and rejection conditions. Add @returns {Promise<void>} and note that rejections are caught internally and surfaced via toast, so no rejection propagates to the caller.

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/hooks/use-direct-message.ts:

Line 20 to 21:

Missing JSDoc: async function `openDirectMessage` returns a Promise but lacks documentation for its resolve value and rejection conditions. Add `@returns {Promise<void>}` and note that rejections are caught internally and surfaced via toast, so no rejection propagates to the caller.

Talk to Kody by mentioning @kody

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

if (!targetUserId) {
// External lane leads have a name and phone but no Resgrid account to message.
useToastStore.getState().showToast('info', t('incident_command.dm_unavailable'));
return;
}

setIsOpening(true);
try {
const channel = await createDirectMessage({ TargetUserId: targetUserId });
Comment on lines +12 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
fd -i 'use-direct-message|incident-command-tab-panel|incident-chat-section|chat' src | head -80

printf '%s\n' '--- package/react-query references ---'
rg -n --glob 'package.json' --glob 'src/**/*.{ts,tsx}' '`@tanstack/react-query`|useMutation|createDirectMessage|CreateDirectMessage|isOpening|openDirectMessage' .

printf '%s\n' '--- hook outline ---'
ast-grep outline src/hooks/use-direct-message.ts

printf '%s\n' '--- hook ---'
cat -n src/hooks/use-direct-message.ts

printf '%s\n' '--- caller sections ---'
cat -n src/components/incident-command/incident-command-tab-panel.tsx | sed -n '85,150p'
cat -n src/components/incident-command/incident-chat-section.tsx | sed -n '60,155p'

printf '%s\n' '--- chat API ---'
cat -n src/api/chat/chat.ts | sed -n '1,100p'

printf '%s\n' '--- direct-message-related files ---'
rg -n -i --glob '!node_modules' 'direct.?message|CreateDirectMessage|TargetUserId|participant' src app server api .github 2>/dev/null | head -200

Repository: Resgrid/Unit

Length of output: 41358


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- tracked endpoint/idempotency evidence ---'
git ls-files | xargs -r rg -n -i 'CreateDirectMessage|create.?direct.?message|normalized participant|participant.?key|dedup|idempot' -- 2>/dev/null | head -300

printf '%s\n' '--- component declarations and relevant caller context ---'
cat -n src/components/incident-command/incident-command-tab-panel.tsx | sed -n '1,145p'
cat -n src/components/incident-command/incident-chat-section.tsx | sed -n '1,90p'

printf '%s\n' '--- React Query usage ---'
rg -n --glob 'src/**/*.{ts,tsx}' 'useMutation|mutationFn|mutateAsync|isPending|QueryClient|useQuery' src | head -240

printf '%s\n' '--- tests and test setup related to these components ---'
rg -n --glob 'src/components/incident-command/**/*.{ts,tsx}' 'useDirectMessage|message|Pressable|disabled|CreateDirectMessage|router.push' src/components/incident-command
cat -n package.json | sed -n '65,90p'

printf '%s\n' '--- state timing model: two invocations before a React state commit ---'
node - <<'JS'
let isOpening = false;
let requests = 0;
const pendingState = [];

function setIsOpening(value) {
  pendingState.push(value);
}

function openDirectMessage() {
  if (!isOpening) {
    setIsOpening(true);
    requests += 1;
  }
}

openDirectMessage();
openDirectMessage();

console.log(JSON.stringify({ requests, committedStateBeforeFlush: isOpening, queuedStateUpdates: pendingState }));
JS

Repository: Resgrid/Unit

Length of output: 19945


🌐 Web query:

Resgrid CreateDirectMessage endpoint server dedup normalized participant key

💡 Result:

The Resgrid API (v4) architecture utilizes standard REST endpoints and Swagger documentation [1][2]. There is no public, documented API endpoint named CreateDirectMessage that utilizes a "server dedup normalized participant key" in the official Resgrid documentation or public GitHub repositories [1][3][4]. Resgrid's messaging and dispatch systems are generally managed through the Calls, Personnel, and Workflows API areas [2][5]. In the context of Resgrid's internal architecture, developers often work with the Resgrid.Core repository, where services and providers handle logic such as messaging, caching, and worker queue processing [4][6]. The platform employs a Cache-Aside pattern using Redis for performance optimization [6], but specific deduplication logic involving a "normalized participant key" for a "CreateDirectMessage" operation is not an exposed public API feature [1][2]. If you are developing against the Resgrid platform, you should refer to the official Resgrid API Swagger documentation (typically available at https://api.resgrid.com/index.html) to identify the currently supported endpoints for your version [2][7]. For self-hosted deployments, you may inspect the Resgrid.Core source code directly to understand how internal messaging and participant handling are implemented within your specific environment version [4][8].

Citations:


Make direct-message opening single-flight.

isOpening is never checked, so two taps can issue two requests and call router.push twice. ContactRow creates a separate hook instance for each row, so a per-hook guard will not cover all buttons. Use a shared React Query v5 mutation or lock, guard pending calls, and disable every direct-message Pressable. Do not rely on server deduplication without a CreateDirectMessage idempotency guarantee.

🤖 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/hooks/use-direct-message.ts` around lines 12 - 30, Make
useDirectMessage’s opening flow single-flight across all hook instances by using
a shared React Query v5 mutation or module-level lock, and guard calls while one
request is pending. Update every direct-message Pressable, including ContactRow
consumers, to disable while the shared operation is pending; preserve the
existing unavailable-user handling and only allow router.push after the guarded
request completes.

Source: Coding guidelines

const channelId = channel?.Data?.ChatChannelId;
if (!channelId) {
useToastStore.getState().showToast('error', t('incident_command.dm_failed'));
return;
}
router.push(`/chat/${channelId}`);
} catch (error) {
logger.error({ message: 'chat: failed to open direct message', context: { error, targetUserId } });
useToastStore.getState().showToast('error', t('incident_command.dm_failed'));
} finally {
setIsOpening(false);
}
},
[t]
);

return { openDirectMessage, isOpening };
};
4 changes: 4 additions & 0 deletions src/models/v4/chat/chatEnums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export enum ChatChannelType {
IncidentLane = 6,
IncidentCommand = 7,
Chatbot = 8,
/** IC plus every lane's primary/secondary lead — command talking to the people running the lanes. */
IncidentLeads = 9,
/** The incident's line to the dispatch desk: everyone on the incident, plus every authorized dispatcher. */
IncidentDispatch = 10,
}

/** Message type (ChatMessageResultData.MessageType). */
Expand Down
31 changes: 31 additions & 0 deletions src/models/v4/incidentCommand/resourceIncidentView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,33 @@ export class ResourceLaneAssignmentView {
public LinkedNeed?: IncidentNeed | null = null;
}

/** Who holds an ICS position on the incident, with the contact details to reach them. */
export class IncidentRoleContactInfo {
/** Maps to IncidentRoleType. */
public RoleType: number = 0;
public Contact?: IncidentContactInfo | null = null;
}

/**
* The incident's chat channels, already filtered by the server to the ones this caller may open.
* A null id means "not available to you" — not command staff, not a lane lead, or not provisioned.
* Never infer access from anything else: if the id isn't here, the channel will reject you.
*/
export class IncidentChatChannels {
/** Call-wide incident channel (everyone on the call). */
public IncidentChannelId?: string | null = null;
/** Private command channel — command staff (IC or an ICS role holder) only. */
public CommandChannelId?: string | null = null;
/** "All Leads" channel — the IC and lane primary/secondary leads only. */
public LeadsChannelId?: string | null = null;
/** The caller's own lane channel, when they are assigned to a lane. */
public LaneChannelId?: string | null = null;
/** The incident's line to the dispatch desk — open to everyone on the incident. */
public DispatchChannelId?: string | null = null;
/** True once the incident is closed: readable, but frozen as a point-in-time record. */
public IsFrozen: boolean = false;
}

export class ResourceIncidentView {
public IncidentCommandId: string = '';
public CallId: number = 0;
Expand All @@ -136,4 +163,8 @@ export class ResourceIncidentView {
public Notes: IncidentNote[] = [];
public Attachments: IncidentAttachment[] = [];
public MyAssignment?: ResourceLaneAssignmentView | null = null;
/** ICS positions filled on this incident, so a crew can reach the right person directly. */
public Roles?: IncidentRoleContactInfo[] = [];
/** Chat channels this caller may open. */
public Chat?: IncidentChatChannels | null = null;
}
Loading
Loading