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
19 changes: 19 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
import '@testing-library/react-native/extend-expect';

// Mock react-native-safe-area-context — its source build reads StyleSheet at import time,
// which explodes in suites that stub react-native with a minimal factory (e.g. navigation tests).
jest.mock('react-native-safe-area-context', () => {
const React = require('react');

const SafeAreaView = ({ children }: any) => React.createElement(React.Fragment, null, children);

return {
SafeAreaView,
SafeAreaProvider: ({ children }: any) => children,
useSafeAreaInsets: jest.fn(() => ({ top: 0, bottom: 0, left: 0, right: 0 })),
useSafeAreaFrame: jest.fn(() => ({ x: 0, y: 0, width: 375, height: 667 })),
initialWindowMetrics: {
insets: { top: 0, bottom: 0, left: 0, right: 0 },
frame: { x: 0, y: 0, width: 375, height: 667 },
},
};
});

// Mock @sentry/react-native — native module (RNSentry) is unavailable in Jest
jest.mock('@sentry/react-native', () => ({
captureException: jest.fn(),
Expand Down
17 changes: 11 additions & 6 deletions plugins/__tests__/with-app-icon-badge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ interface TestExpoConfig {

const withAppIconBadge = jest.requireActual('../with-app-icon-badge.js') as (config: TestExpoConfig, options?: AppIconBadgeConfig) => TestExpoConfig;

// The plugin builds absolute paths with node:path, so expected values must go through
// path.resolve too — otherwise the assertions only hold on POSIX separators.
const nodePath = jest.requireActual('node:path') as typeof import('node:path');
const projectPath = (...segments: string[]) => nodePath.resolve('/project', ...segments);

describe('withAppIconBadge', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -56,18 +61,18 @@ describe('withAppIconBadge', () => {
};
expect(payload.jobs).toEqual([
{
sourcePath: '/project/assets/icon.png',
outputPath: '/project/.expo/app-icon-badge/icon.png',
sourcePath: projectPath('assets/icon.png'),
outputPath: projectPath('.expo/app-icon-badge/icon.png'),
isAdaptiveIcon: false,
},
{
sourcePath: '/project/assets/ios-icon.png',
outputPath: '/project/.expo/app-icon-badge/ios-icon.png',
sourcePath: projectPath('assets/ios-icon.png'),
outputPath: projectPath('.expo/app-icon-badge/ios-icon.png'),
isAdaptiveIcon: false,
},
{
sourcePath: '/project/assets/adaptive-icon.png',
outputPath: '/project/.expo/app-icon-badge/foregroundImage.png',
sourcePath: projectPath('assets/adaptive-icon.png'),
outputPath: projectPath('.expo/app-icon-badge/foregroundImage.png'),
isAdaptiveIcon: true,
},
]);
Expand Down
10 changes: 8 additions & 2 deletions scripts/__tests__/extract-release-notes.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';

const scriptPath = path.join(process.cwd(), 'scripts/extract-release-notes.sh');
// Forward slashes so the path survives bash on Windows (Git Bash accepts G:/... paths;
// backslashes would be eaten as escape characters). No-op on POSIX.
const scriptPath = path.join(process.cwd(), 'scripts/extract-release-notes.sh').replace(/\\/g, '/');

const extractReleaseNotes = (body: string): string => {
const result = spawnSync('bash', [scriptPath, '--extract-only'], {
Expand All @@ -16,7 +18,11 @@ const extractReleaseNotes = (body: string): string => {
return result.stdout.trim();
};

describe('extract-release-notes', () => {
// Skip on Windows: `bash` resolves to WSL/Git Bash and the checked-out .sh file carries
// CRLF endings there ("set: pipefail: invalid option name"). CI runs this suite on Linux.
const describeOnPosix = process.platform === 'win32' ? describe.skip : describe;

describeOnPosix('extract-release-notes', () => {
it.each(['##', '###'])('normalizes a %s PR Description heading', (heading) => {
const notes = extractReleaseNotes(`${heading} PR Description\n\nAdds the release change.`);

Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/security-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('Security Permission Logic', () => {
CanCreateCalls: true,
CanAddNote: false,
CanCreateMessage: false,
CanLoginToCommandApp: true,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Add a denied-access test case.

Every changed fixture sets CanLoginToCommandApp to true. The suite can therefore pass while the explicit false authorization path remains broken. Add a case that denies command-app access and verifies the localized toast and logout behavior. Also cover an omitted field if the API contract treats missing values specially.

As per coding guidelines: generate tests for new components, services, and logic.

Also applies to: 48-48, 70-70, 91-91, 114-114

🤖 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/__tests__/security-integration.test.ts` at line 29, Add a security
integration test alongside the existing CanLoginToCommandApp fixtures that sets
the field to false and verifies the localized access-denied toast and logout
behavior; also add coverage for an omitted field if the API contract
distinguishes missing authorization from false. Keep the existing authorized
cases unchanged and follow the suite’s established test setup and assertions.

Source: Coding guidelines

Groups: []
};

Expand All @@ -44,6 +45,7 @@ describe('Security Permission Logic', () => {
CanCreateCalls: false,
CanAddNote: true,
CanCreateMessage: true,
CanLoginToCommandApp: true,
Groups: []
};

Expand All @@ -65,6 +67,7 @@ describe('Security Permission Logic', () => {
CanViewPII: true,
CanAddNote: true,
CanCreateMessage: true,
CanLoginToCommandApp: true,
Groups: []
} as unknown as DepartmentRightsResultData;

Expand All @@ -85,6 +88,7 @@ describe('Security Permission Logic', () => {
CanCreateCalls: true,
CanAddNote: false,
CanCreateMessage: false,
CanLoginToCommandApp: true,
Groups: []
};

Expand All @@ -107,6 +111,7 @@ describe('Security Permission Logic', () => {
CanCreateCalls: false,
CanAddNote: true,
CanCreateMessage: true,
CanLoginToCommandApp: true,
Groups: []
};

Expand Down
16 changes: 14 additions & 2 deletions src/api/chat/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,21 @@ const MODERATION = '/ChatModeration';
// Channels
// ---------------------------------------------------------------------------

export const getChannels = async (activeUnitId?: number, signal?: AbortSignal) => {
/**
* The caller's channels. `includeArchived` pulls in the point-in-time record of closed incidents and
* calls — off by default so the everyday list stays current.
*/
export const getChannels = async (activeUnitId?: number, includeArchived = false, signal?: AbortSignal) => {
const params: Record<string, unknown> = {};
if (activeUnitId != null) {
params.activeUnitId = activeUnitId;
}
if (includeArchived) {
params.includeArchived = true;
}

const response = await api.get<ChatV4Response<ChatChannelResultData[]>>(`${CHAT}/GetChannels`, {
params: activeUnitId != null ? { activeUnitId } : undefined,
params: Object.keys(params).length > 0 ? params : undefined,
signal,
});
return response.data;
Expand Down
47 changes: 34 additions & 13 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store
import { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
import { useToastStore } from '@/stores/toast/store';
import { useWeatherAlertsStore } from '@/stores/weather-alerts/store';

export default function TabLayout() {
Expand Down Expand Up @@ -175,6 +176,18 @@ export default function TabLayout() {
await useCallsStore.getState().init();
await useWeatherAlertsStore.getState().init();
await securityStore.getState().getRights();

// The IC app is for commanders. A member the department has not authorized must not get past
// initialization — the server refuses them the board endpoints anyway, so signing them straight
// back out is far clearer than an app that loads and then fails every request.
if (!isCurrentRun()) return;
if (securityStore.getState().rights?.CanLoginToCommandApp === false) {

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

Authorization gate fails open. The check for explicit false (=== false) instead of requiring explicit true (!== true) lets null rights or missing CanLoginToCommandApp proceed past the gate, defeating deny-by-default and silently failing the client-side clean sign-out intent. Change to if (securityStore.getState().rights?.CanLoginToCommandApp !== true) so any non-confirmed permission blocks access.

Kody rule violation: Implement RBAC with least privilege and deny-by-default

Prompt for LLM

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

Line 184:

Authorization gate fails open. The check for explicit `false` (`=== false`) instead of requiring explicit `true` (`!== true`) lets `null` `rights` or missing `CanLoginToCommandApp` proceed past the gate, defeating deny-by-default and silently failing the client-side clean sign-out intent. Change to `if (securityStore.getState().rights?.CanLoginToCommandApp !== true)` so any non-confirmed permission blocks access.

Talk to Kody by mentioning @kody

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

logger.warn({ message: 'User is not authorized to use the IC app; signing out', context: { userId } });

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

Non-compliant security audit logging. The unauthorized access attempt is logged via logger.warn with only message and context: { userId }, missing actor.role, action (as a structured field), resource.id, result, trace_id, ip, and user_agent. Rule 41 requires security-relevant actions to write to an immutable, append-only audit log with structured fields plus WORM/signed storage and SIEM forwarding. Emit a dedicated audit record via auditLog.write({ action: 'command_app.access_denied', actor: { user_id: userId, role }, resource: { id: 'command_app' }, result: 'denied', trace_id, ip, user_agent, timestamp: new Date().toISOString() }).

Kody rule violation: Emit tamper-evident audit logs with required fields

Prompt for LLM

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

Line 185:

Non-compliant security audit logging. The unauthorized access attempt is logged via `logger.warn` with only `message` and `context: { userId }`, missing actor.role, action (as a structured field), resource.id, result, trace_id, ip, and user_agent. Rule 41 requires security-relevant actions to write to an immutable, append-only audit log with structured fields plus WORM/signed storage and SIEM forwarding. Emit a dedicated audit record via `auditLog.write({ action: 'command_app.access_denied', actor: { user_id: userId, role }, resource: { id: 'command_app' }, result: 'denied', trace_id, ip, user_agent, timestamp: new Date().toISOString() })`.

Talk to Kody by mentioning @kody

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

useToastStore.getState().showToast('error', t('login.command_not_authorized'));
await useAuthStore.getState().logout();
return;
}

await featureFlagsStore.getState().fetchFlags();

if (!isCurrentRun()) return;
Expand Down Expand Up @@ -235,7 +248,7 @@ export default function TabLayout() {
setIsInitComplete(true);
}
}
}, [status]);
}, [status, t, userId]);

const refreshDataFromBackground = useCallback(async () => {
if (status !== 'signedIn' || !hasInitialized.current) return;
Expand Down Expand Up @@ -501,24 +514,29 @@ export default function TabLayout() {
[t, headerLeftBack, headerRightNotification]
);

// chat + chatbot are routable (sidebar menu links) but hidden from the tab bar (href: null);
// each screen renders its own in-screen header/toolbar, so the tab header is disabled.
// chat + chatbot are routable (sidebar menu links) but hidden from the tab bar (href: null).
// They keep the app header: it is the only way back out, since neither is on the tab bar and
// their in-screen toolbars carry actions rather than navigation.
const chatOptions = useMemo(
() => ({
href: null,
title: t('chat.title'),
headerShown: false as const,
headerShown: true as const,
headerLeft: headerLeftMap,
headerRight: headerRightNotification,
}),
[t]
[t, headerLeftMap, headerRightNotification]
);

const chatbotOptions = useMemo(
() => ({
href: null,
title: t('chatbot.title'),
headerShown: false as const,
headerShown: true as const,
headerLeft: headerLeftMap,
headerRight: headerRightNotification,
}),
[t]
[t, headerLeftMap, headerRightNotification]
);

// settings stays routable (sidebar menu link) but is hidden from the tab bar.
Expand Down Expand Up @@ -628,23 +646,26 @@ interface CreateDrawerMenuButtonProps {
const CreateDrawerMenuButton = ({ setIsOpen }: CreateDrawerMenuButtonProps) => {
return (
<Pressable
className="p-2"
hitSlop={4}
className="p-3"
hitSlop={8}
testID="drawer-menu-button"
onPress={() => {
setIsOpen(true);
}}
>
<Menu size={24} color="currentColor" className="text-gray-700 dark:text-gray-300" />
{/* Routed through the Icon wrapper, not a bare lucide element: className alone never reaches a
raw lucide icon (no cssInterop is registered for them), so it falls back to currentColor and
renders solid black — invisible against a dark header. */}
<Icon as={Menu} size={24} className="text-gray-700 dark:text-gray-200" />

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/app/(app)/_layout.tsx'

printf '%s\n' '--- file outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline "$file" || true
fi

printf '%s\n' '--- relevant source ---'
sed -n '1,90p' "$file"
sed -n '610,700p' "$file"

printf '%s\n' '--- icon imports and usages in target file ---'
rg -n -C 3 'Icon|Menu|ArrowLeft|Pressable|Touchable|accessib' "$file"

printf '%s\n' '--- repository guidance and comparable usage ---'
rg -n -g '*.{ts,tsx,md,json}' 'lucide-react-native|Icon as=|accessibilityLabel|aria-label|<Menu|<ArrowLeft' . | head -250

Repository: Resgrid/IC

Length of output: 47271


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- Icon and Pressable implementations ---'
icon_file=$(fd -i -t f '^icon\.(tsx|ts)$' src/components/ui | head -1)
pressable_file=$(fd -i -t f '^pressable\.(tsx|ts)$' src/components/ui | head -1)
printf 'icon_file=%s\npressable_file=%s\n' "$icon_file" "$pressable_file"
cat -n "$icon_file"
cat -n "$pressable_file"

printf '%s\n' '--- theme and color scheme usage ---'
rg -n -g '*.{ts,tsx}' 'useColorScheme|colorScheme|resolvedColor|theme.*color|ColorValue|--color|dark:' src | head -250

printf '%s\n' '--- accessible header controls and translation keys ---'
rg -n -C 4 -g '*.{ts,tsx,json}' 'common\.back|common\.menu|accessibilityLabel=.*(back|menu)|header-back|drawer-menu' src | head -250

printf '%s\n' '--- project icon rules ---'
sed -n '1,35p' AGENTS.md
sed -n '360,385p' AGENTS.md

Repository: Resgrid/IC

Length of output: 253


🏁 Script executed:

#!/bin/bash
set -u

printf '%s\n' '--- tracked UI files ---'
git ls-files 'src/components/ui/*' | head -200

printf '%s\n' '--- Icon definitions ---'
rg -n -C 8 'export.*Icon|const Icon|function Icon|createIcon' src/components/ui src | head -250

printf '%s\n' '--- Pressable definitions ---'
rg -n -C 8 'export.*Pressable|const Pressable|function Pressable|createPressable' src/components/ui src | head -250

printf '%s\n' '--- theme and color scheme usage ---'
rg -n -g '*.{ts,tsx}' 'useColorScheme|colorScheme|resolvedColor|theme.*color|ColorValue|--color' src | head -250

printf '%s\n' '--- accessible header controls and translation keys ---'
rg -n -C 4 -g '*.{ts,tsx,json}' 'common\.back|common\.menu|accessibilityLabel=.*(back|menu)|header-back|drawer-menu' src | head -250

printf '%s\n' '--- project icon rules ---'
sed -n '1,35p' AGENTS.md
sed -n '360,385p' AGENTS.md

Repository: Resgrid/IC

Length of output: 50366


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- native Icon wrapper ---'
cat -n src/components/ui/icon/index.tsx | sed -n '1,85p'

printf '%s\n' '--- translation files and relevant keys ---'
git ls-files | rg '(^|/)(locales?|translations?|i18n)(/|\\.)|\\.json$' | head -100
rg -n -C 2 '"(back|menu|drawer|navigation)"|common\.(back|menu)' . \
  -g '*.json' -g '*.{ts,tsx}' \
  --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' | head -180

printf '%s\n' '--- direct Lucide color usage ---'
rg -n -C 2 '<(Menu|ArrowLeft|ChevronRight|Search|X)[^>]*(color=|className=)' src -g '*.{ts,tsx}' | head -120

Repository: Resgrid/IC

Length of output: 23306


Render the header icons directly and label the controls.

The project rules require direct lucide-react-native icons. Replace Icon as={Menu} and Icon as={ArrowLeft} with direct components and pass theme-resolved color values. Add translated labels using t('sidebar.menu') and t('common.back').

🤖 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)/_layout.tsx at line 659, Update the header controls in the
layout to render Menu and ArrowLeft directly from lucide-react-native instead of
through Icon’s as prop, supplying theme-resolved color values. Add translated
accessibility labels using t('sidebar.menu') for the menu control and
t('common.back') for the back control.

Source: Coding guidelines

</Pressable>
Comment on lines 648 to 660

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -e
file='src/app/(app)/_layout.tsx'
printf '%s\n' '--- target section ---'
sed -n '620,695p' "$file"
printf '%s\n' '--- relevant imports and translation setup ---'
sed -n '1,120p' "$file"
printf '%s\n' '--- Pressable and accessibility usages in this file ---'
rg -n -C 3 'Pressable|accessibility(Label|Role|Hint)' "$file"

Repository: Resgrid/IC

Length of output: 9224


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Pressable implementation ---'
pressable=$(fd -i '^pressable\.(tsx|ts)$' src | head -n 1)
printf 'file=%s\n' "$pressable"
sed -n '1,220p' "$pressable"
printf '%s\n' '--- localized icon-button patterns ---'
rg -n -C 4 'accessibilityLabel|accessibilityRole' src --glob '*.{ts,tsx}' | head -n 240
printf '%s\n' '--- translation keys related to navigation and drawer ---'
rg -n -i 'drawer|menu|back|navigation' src --glob '*.{json,ts,tsx}' | head -n 240

Repository: Resgrid/IC

Length of output: 231


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- candidate Pressable files ---'
git ls-files | rg '(^|/)pressable\.(tsx|ts)$|(^|/)Pressable\.(tsx|ts)$'
printf '%s\n' '--- UI component directory ---'
git ls-files 'src/components/ui/*' | rg -i 'pressable|button|icon|text'
printf '%s\n' '--- accessibility API usage ---'
rg -n -C 4 'accessibilityLabel|accessibilityRole' src --glob '*.{ts,tsx}' | head -n 240
printf '%s\n' '--- navigation translation references ---'
rg -n -i -C 3 'drawer|menu|back|navigation' src --glob '*.{json,ts,tsx}' | head -n 240

Repository: Resgrid/IC

Length of output: 182


🏁 Script executed:

#!/bin/bash
printf '%s\n' '--- repository files under the imported UI path ---'
find src/components/ui -maxdepth 3 -type f 2>/dev/null | sort | rg -i 'pressable|button|icon|text' || true
printf '%s\n' '--- Pressable declarations and imports ---'
rg -n -C 5 'export .*Pressable|const Pressable|function Pressable|from .*pressable' src --glob '*.{ts,tsx}' || true
printf '%s\n' '--- accessibility API usage ---'
rg -n -C 4 'accessibilityLabel|accessibilityRole' src --glob '*.{ts,tsx}' | head -n 240 || true
printf '%s\n' '--- navigation translation references ---'
rg -n -i -C 3 'drawer|menu|back|navigation' src --glob '*.{json,ts,tsx}' | head -n 240 || true

Repository: Resgrid/IC

Length of output: 50366


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- Pressable wrapper ---'
cat -n src/components/ui/pressable/index.tsx
printf '%s\n' '--- existing localized accessibility labels ---'
rg -n -C 2 'accessibilityLabel=' src --glob '*.{ts,tsx}' | head -n 160
printf '%s\n' '--- translation resource files ---'
find . -type f \( -name '*.json' -o -name '*.ts' -o -name '*.tsx' \) -not -path './node_modules/*' | rg -i 'locale|locales|translation|i18n|lang' | head -n 120
printf '%s\n' '--- common accessibility/navigation keys ---'
rg -n -i -C 2 'dismiss|drawer|menu|back|accessibility' . --glob '*.json' --glob '*.ts' --glob '*.tsx' --glob '!node_modules/**' | head -n 220

Repository: Resgrid/IC

Length of output: 50367


🏁 Script executed:

#!/bin/bash
set -e
python3 - <<'PY'
import json
from pathlib import Path

for name in ('en', 'fr', 'es', 'de', 'it', 'pl', 'sv', 'uk', 'ar'):
    path = Path('src/translations') / f'{name}.json'
    data = json.loads(path.read_text())
    common = data.get('common', {})
    print(name, {
        'back': common.get('back'),
        'menu': common.get('menu'),
        'open_menu': common.get('open_menu'),
        'open_drawer': common.get('open_drawer'),
    })
PY
printf '%s\n' '--- comparable back-button implementations ---'
rg -n -C 5 "testID=.*back|accessibilityLabel=.*common.back|ArrowLeft" src/app src/components --glob '*.{ts,tsx}' | head -n 180

Repository: Resgrid/IC

Length of output: 14457


Add accessible names to both navigation buttons.

Set accessibilityRole="button" and localized accessibilityLabel values on CreateDrawerMenuButton and CreateHeaderBackButton. Reuse t('common.back') for the back button and add a translated drawer/menu key. testID does not provide an accessible name for VoiceOver or TalkBack users.

🤖 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)/_layout.tsx around lines 648 - 660, Add
accessibilityRole="button" and localized accessibilityLabel props to both
CreateDrawerMenuButton and CreateHeaderBackButton. Use t('common.back') for the
back button and add or reuse a translated drawer/menu label for the drawer
button; keep the existing testIDs and press behavior unchanged.

Source: Coding guidelines

);
};

const CreateHeaderBackButton = () => {
return (
<Pressable
className="p-2"
hitSlop={4}
className="p-3"
hitSlop={8}
testID="header-back-button"
onPress={() => {
if (router.canGoBack()) {
Expand All @@ -654,7 +675,7 @@ const CreateHeaderBackButton = () => {
}
}}
>
<ArrowLeft size={24} color="currentColor" className="text-gray-700 dark:text-gray-300" />
<Icon as={ArrowLeft} size={24} className="text-gray-700 dark:text-gray-200" />
</Pressable>
);
};
Expand Down
20 changes: 8 additions & 12 deletions src/app/(app)/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Href, Redirect, Stack, useFocusEffect, useRouter } from 'expo-router';
import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native';
import { type Href, Redirect, useFocusEffect, useRouter } from 'expo-router';
import { Bot, MessageCircle, Network, Plus, Sparkles, Users } from 'lucide-react-native';
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshControl, ScrollView } from 'react-native';
Expand Down Expand Up @@ -119,7 +119,6 @@ export default function ChatScreen() {
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
<FocusAwareStatusBar />
<Spinner />
</Box>
Expand All @@ -133,17 +132,14 @@ export default function ChatScreen() {

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
<FocusAwareStatusBar />

{/* In-screen toolbar (the app drawer provides the top nav bar). */}
<HStack className="items-center justify-between border-b border-outline-100 px-4 py-2">
<HStack className="items-center" space="sm">
<MessagesSquare size={22} color="#2563eb" />
<Text className="text-lg font-bold text-typography-900">{t('chat.title')}</Text>
</HStack>
<Pressable onPress={() => router.push('/chatbot' as Href)} accessibilityLabel={t('chat.assistant')}>
<Sparkles size={22} color="#7c3aed" />
{/* Shortcut across to the assistant. The app header above carries the title and the way back,
so this row is actions only. */}
<HStack className="items-center justify-end border-b border-outline-100 px-4 py-2">
<Pressable className="flex-row items-center rounded-full bg-purple-600 px-3 py-2" onPress={() => router.push('/chatbot' as Href)} accessibilityLabel={t('chat.assistant')} testID="chat-open-assistant">

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 regression in src/app/(app)/chat.tsx (and 17 additional locations including chatbot.tsx, command.tsx, landscape-structure-board.tsx, lane-details-sheet.tsx, and structure-section.tsx). Inline arrow functions inside JSX props create new function instances on every render, violating the team rule against .bind() or arrow functions in JSX props. Move these function definitions 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 140:

Performance regression in `src/app/(app)/chat.tsx` (and 17 additional locations including `chatbot.tsx`, `command.tsx`, `landscape-structure-board.tsx`, `lane-details-sheet.tsx`, and `structure-section.tsx`). Inline arrow functions inside JSX props create new function instances on every render, violating the team rule against `.bind()` or arrow functions in JSX props. Move these function definitions outside the render method.

Talk to Kody by mentioning @kody

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

<Sparkles size={16} color="#ffffff" />
<Text className="ml-1 text-xs font-medium text-white">{t('chat.assistant')}</Text>
</Pressable>
</HStack>

Expand Down
24 changes: 13 additions & 11 deletions src/app/(app)/chatbot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Redirect, Stack, useFocusEffect } from 'expo-router';
import { Redirect, useFocusEffect } from 'expo-router';
import { RefreshCw, Send, Sparkles } from 'lucide-react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -17,7 +17,6 @@ import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view';
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 { type ChatMessageResultData } from '@/models/v4/chat';
import useAuthStore from '@/stores/auth/store';
import { useChatStore } from '@/stores/chat/store';
Expand Down Expand Up @@ -67,7 +66,14 @@ export default function ChatbotScreen() {

const renderItem = useCallback(
({ item }: { item: ChatMessageResultData }) => (
<MessageBubble message={item} isOwn={!!item.SenderUserId && item.SenderUserId === currentUserId} showSender={false} currentUserId={currentUserId} onLongPress={setActionsMessage} onToggleReaction={() => undefined} />
<MessageBubble
message={item}
isOwn={!!item.SenderUserId && item.SenderUserId === currentUserId}
showSender={false}
currentUserId={currentUserId}
onLongPress={setActionsMessage}
onToggleReaction={() => undefined}
/>
),
[currentUserId]
);
Expand All @@ -76,7 +82,6 @@ export default function ChatbotScreen() {
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
<FocusAwareStatusBar />
<Spinner />
</Box>
Expand All @@ -90,19 +95,16 @@ export default function ChatbotScreen() {

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ headerShown: false }} />
<FocusAwareStatusBar />

{/* Distinct assistant header */}
{/* Assistant identity strip. The title lives in the app header above, so this keeps only the
mark, the one-line description, and the reset action. */}
<HStack className="items-center justify-between border-b border-outline-100 bg-purple-50 px-4 py-2 dark:bg-purple-950">
<HStack className="items-center" space="sm">
<HStack className="min-w-0 flex-1 items-center" space="sm">
<Box className="size-8 items-center justify-center rounded-full bg-purple-600">
<Sparkles size={18} color="#ffffff" />
</Box>
<VStack>
<Text className="text-base font-bold text-typography-900">{t('chatbot.title')}</Text>
<Text className="text-xs text-typography-400">{t('chatbot.subtitle')}</Text>
</VStack>
<Text className="min-w-0 flex-1 text-xs text-typography-500">{t('chatbot.subtitle')}</Text>
</HStack>
<Pressable className="flex-row items-center rounded-full bg-purple-100 px-3 py-1 dark:bg-purple-900" onPress={() => useChatStore.getState().newChatbotSession()} accessibilityLabel={t('chatbot.new_session')}>
<RefreshCw size={14} color="#7c3aed" />
Expand Down
Loading
Loading