diff --git a/services/platform/app/components/theme/theme-provider.tsx b/services/platform/app/components/theme/theme-provider.tsx index ce99f13c8..c86334ca0 100644 --- a/services/platform/app/components/theme/theme-provider.tsx +++ b/services/platform/app/components/theme/theme-provider.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, + useMemo, useState, useCallback, type ReactNode, @@ -90,11 +91,10 @@ export function ThemeProvider({ [updateResolvedTheme], ); - const value: ThemeContextValue = { - theme, - resolvedTheme, - setTheme, - }; + const value = useMemo( + () => ({ theme, resolvedTheme, setTheme }), + [theme, resolvedTheme, setTheme], + ); return ( {children} diff --git a/services/platform/app/features/approvals/components/approvals-client.tsx b/services/platform/app/features/approvals/components/approvals-client.tsx index 903cf64eb..6f1d7072d 100644 --- a/services/platform/app/features/approvals/components/approvals-client.tsx +++ b/services/platform/app/features/approvals/components/approvals-client.tsx @@ -422,6 +422,10 @@ export function ApprovalsClient({ return ( {list.map((p, index) => { + const id = + safeGetString(p, 'productId', '') || + safeGetString(p, 'id', '') || + String(index); const name = safeGetString(p, 'name', '') || safeGetString(p, 'productName', ''); @@ -431,7 +435,7 @@ export function ApprovalsClient({ '/assets/placeholder-image.png'; return ( - +
{ + const [edgePath, defaultLabelX, defaultLabelY] = useMemo(() => { if (type === 'smoothstep' || type === 'default') { return getSmoothStepPath({ sourceX, @@ -62,7 +62,6 @@ export function AutomationEdge({ targetY, }); } - // bezier (or fallback) return getBezierPath({ sourceX, sourceY, @@ -71,42 +70,34 @@ export function AutomationEdge({ targetY, targetPosition, }); - })(); + }, [type, sourceX, sourceY, sourcePosition, targetX, targetY, targetPosition]); - // Calculate smart label position - const calculateLabelPosition = () => { + const { labelX, labelY } = useMemo(() => { const labelPosition = data?.labelPosition || 'center'; - let labelX = defaultLabelX; - let labelY = defaultLabelY; + let lx = defaultLabelX; + let ly = defaultLabelY; - // Adjust position based on labelPosition setting if (labelPosition === 'source') { - // Position label 25% from source - labelX = sourceX + (targetX - sourceX) * 0.25; - labelY = sourceY + (targetY - sourceY) * 0.25; + lx = sourceX + (targetX - sourceX) * 0.25; + ly = sourceY + (targetY - sourceY) * 0.25; } else if (labelPosition === 'target') { - // Position label 75% toward target - labelX = sourceX + (targetX - sourceX) * 0.75; - labelY = sourceY + (targetY - sourceY) * 0.75; + lx = sourceX + (targetX - sourceX) * 0.75; + ly = sourceY + (targetY - sourceY) * 0.75; } - // Special handling for backward connections if (data?.isBackwardConnection) { - // Offset label to the side for backward connections to avoid overlap - const offsetX = 30; // Offset to the right - labelX += offsetX; + lx += 30; } - // Apply manual offset if provided - if (data?.labelOffset) { - labelX += data.labelOffset.x; - labelY += data.labelOffset.y; + if (data?.labelOffset?.x) { + lx += data.labelOffset.x; + } + if (data?.labelOffset?.y) { + ly += data.labelOffset.y; } - return { labelX, labelY }; - }; - - const { labelX, labelY } = calculateLabelPosition(); + return { labelX: lx, labelY: ly }; + }, [defaultLabelX, defaultLabelY, sourceX, sourceY, targetX, targetY, data?.labelPosition, data?.isBackwardConnection, data?.labelOffset?.x, data?.labelOffset?.y]); return ( <> diff --git a/services/platform/app/features/automations/components/automation-sidepanel.tsx b/services/platform/app/features/automations/components/automation-sidepanel.tsx index 1175ddf6e..dcbd575b2 100644 --- a/services/platform/app/features/automations/components/automation-sidepanel.tsx +++ b/services/platform/app/features/automations/components/automation-sidepanel.tsx @@ -382,8 +382,8 @@ export function AutomationSidePanel({ {t('sidePanel.validationErrors')}
    - {errors.map((error, i) => ( -
  • • {error}
  • + {errors.map((error, index) => ( +
  • • {error}
  • ))}
@@ -396,8 +396,8 @@ export function AutomationSidePanel({ {t('sidePanel.validationWarnings')}
    - {warnings.map((warning, i) => ( -
  • • {warning}
  • + {warnings.map((warning, index) => ( +
  • • {warning}
  • ))}
diff --git a/services/platform/app/features/automations/components/automation-tester.tsx b/services/platform/app/features/automations/components/automation-tester.tsx index 2cb7cc76c..ddfc4a5d7 100644 --- a/services/platform/app/features/automations/components/automation-tester.tsx +++ b/services/platform/app/features/automations/components/automation-tester.tsx @@ -205,8 +205,8 @@ export function AutomationTester({ {t('tester.dryRun.errors')}:

    - {dryRunResult.errors.map((err, i) => ( -
  • • {err}
  • + {dryRunResult.errors.map((err, index) => ( +
  • • {err}
  • ))}
@@ -218,8 +218,8 @@ export function AutomationTester({ {t('tester.dryRun.warnings')}:

    - {dryRunResult.warnings.map((warn, i) => ( -
  • • {warn}
  • + {dryRunResult.warnings.map((warn, index) => ( +
  • • {warn}
  • ))}
diff --git a/services/platform/app/features/chat/components/chat-search-dialog.tsx b/services/platform/app/features/chat/components/chat-search-dialog.tsx index fdd3803cc..658d59ff8 100644 --- a/services/platform/app/features/chat/components/chat-search-dialog.tsx +++ b/services/platform/app/features/chat/components/chat-search-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from '@tanstack/react-router'; import { useQuery } from 'convex/react'; import { X } from 'lucide-react'; @@ -30,6 +30,8 @@ export function ChatSearchDialog({ const navigate = useNavigate(); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(-1); + const selectedIndexRef = useRef(selectedIndex); + selectedIndexRef.current = selectedIndex; const inputRef = useRef(null); const debouncedQuery = useDebounce(query, 300); @@ -37,12 +39,19 @@ export function ChatSearchDialog({ const threadsData = useQuery(api.threads.queries.listThreads, { search: debouncedQuery || undefined, }); - const chats = - threadsData?.map((thread) => ({ - _id: thread._id, - title: thread.title ?? t('searchChat.untitledChat'), - createdAt: thread._creationTime, - })) || []; + + const chats = useMemo( + () => + threadsData?.map((thread) => ({ + _id: thread._id, + title: thread.title ?? t('searchChat.untitledChat'), + createdAt: thread._creationTime, + formattedDate: thread._creationTime + ? formatDateSmart(new Date(thread._creationTime)) + : '', + })) ?? [], + [threadsData, t, formatDateSmart], + ); useEffect(() => { if (isOpen) { @@ -68,30 +77,34 @@ export function ChatSearchDialog({ return () => window.removeEventListener('keydown', onKeyDown, true); }, [isOpen, onOpenChange]); + // Reset selection when results change (debounced query), not on every keystroke useEffect(() => { if (isOpen) setSelectedIndex(-1); - }, [isOpen, query]); - - const close = () => onOpenChange(false); - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown') { - e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, chats.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - setSelectedIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === 'Enter' && chats[selectedIndex]) { - navigate({ - to: '/dashboard/$id/chat/$threadId', - params: { id: organizationId, threadId: chats[selectedIndex]._id }, - }); - onOpenChange(false); - } else if (e.key === 'Escape') { - e.preventDefault(); - onOpenChange(false); - } - }; + }, [isOpen, debouncedQuery]); + + const close = useCallback(() => onOpenChange(false), [onOpenChange]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((i) => Math.min(i + 1, chats.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Enter' && chats[selectedIndexRef.current]) { + navigate({ + to: '/dashboard/$id/chat/$threadId', + params: { id: organizationId, threadId: chats[selectedIndexRef.current]._id }, + }); + onOpenChange(false); + } else if (e.key === 'Escape') { + e.preventDefault(); + onOpenChange(false); + } + }, + [chats, navigate, organizationId, onOpenChange], + ); return ( {chat.title} - {chat.createdAt && - formatDateSmart(new Date(chat.createdAt) || '')} + {chat.formattedDate} diff --git a/services/platform/app/features/conversations/components/conversations-list.tsx b/services/platform/app/features/conversations/components/conversations-list.tsx index eb85d7a8c..65e04717b 100644 --- a/services/platform/app/features/conversations/components/conversations-list.tsx +++ b/services/platform/app/features/conversations/components/conversations-list.tsx @@ -1,5 +1,6 @@ 'use client'; +import { memo, useRef } from 'react'; import { Mail, ClipboardList, Sparkles } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; import { Badge } from '@/app/components/ui/feedback/badge'; @@ -185,6 +186,126 @@ function ConversationsListSkeleton() { ); } +interface ConversationRowProps { + conversation: Conversation; + isSelected: boolean; + isChecked: boolean; + onSelect?: (conversation: Conversation) => void; + onCheck?: (conversationId: string, checked: boolean) => void; + formatDateSmart: (date: string | Date) => string; + t: (key: string) => string; + tDialogs: (key: string) => string; +} + +const ConversationRow = memo(function ConversationRow({ + conversation, + isSelected, + isChecked, + onSelect, + onCheck, + formatDateSmart, + t, + tDialogs, +}: ConversationRowProps) { + const handleClick = (event: React.MouseEvent) => { + if ((event.target as HTMLElement).closest('[data-state]')) return; + onSelect?.(conversation); + }; + + const handleCheckboxChange = (checked: boolean | 'indeterminate') => { + if (typeof checked === 'boolean') { + onCheck?.(conversation.id, checked); + } + }; + + return ( + + ); +}); + export function ConversationsList({ conversations, selectedConversationId, @@ -196,141 +317,32 @@ export function ConversationsList({ const { t } = useT('conversations'); const { t: tDialogs } = useT('dialogs'); - // Show skeleton when conversations is undefined (loading) + const tRef = useRef(t); + tRef.current = t; + const tDialogsRef = useRef(tDialogs); + tDialogsRef.current = tDialogs; + + const stableT = useRef(((key: string) => tRef.current(key)) as (key: string) => string).current; + const stableTDialogs = useRef(((key: string) => tDialogsRef.current(key)) as (key: string) => string).current; + if (conversations === undefined) { return ; } - const handleConversationClick = ( - conversation: Conversation, - event: React.MouseEvent, - ) => { - // Prevent conversation selection if clicking on checkbox - if ((event.target as HTMLElement).closest('[data-state]')) { - return; - } - onConversationSelect?.(conversation); - }; - - const handleKeyDown = ( - conversation: Conversation, - event: React.KeyboardEvent, - ) => { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - onConversationSelect?.(conversation); - } - }; - - const handleCheckboxChange = ( - conversationId: string, - checked: boolean | 'indeterminate', - ) => { - if (typeof checked === 'boolean') { - onConversationCheck?.(conversationId, checked); - } - }; - return (
{conversations.map((conversation) => ( -
handleConversationClick(conversation, event)} - onKeyDown={(event) => handleKeyDown(conversation, event)} - tabIndex={0} - role="button" - aria-pressed={selectedConversationId === conversation.id} - > - {/* Blue indicator for selected conversation - no layout shift */} - {selectedConversationId === conversation.id && ( -
- )} -
- {/* Checkbox */} -
- - handleCheckboxChange(conversation.id, checked) - } - aria-label={tDialogs('selectConversation')} - /> -
- - {/* Conversation Details */} -
- {/* Header with title and timestamp */} -
-

- {conversation?.title || conversation.customer?.name || 'Unknown'} -

- - {formatDateSmart(conversation.last_message_at || '')} - -
- - {/* Last message preview with unread message count */} -
-

- {getLastMessagePreview(conversation)} -

- {conversation.unread_count > 0 && ( -
- {conversation.unread_count} -
- )} -
- - {/* Badges */} - - {/* Priority badge */} - {conversation.priority && - conversation.status === 'open' && - conversation.priority !== 'medium' && - conversation.priority in priorityConfig && ( - - {t( - priorityConfig[ - conversation.priority as keyof typeof priorityConfig - ].translationKey, - )} - - )} - - {/* Type badge */} - {conversation.type && conversation.type in categoryConfig && ( - - {t( - categoryConfig[ - conversation.type as keyof typeof categoryConfig - ].translationKey, - )} - - )} - -
-
-
+ conversation={conversation} + isSelected={selectedConversationId === conversation.id} + isChecked={isConversationSelected?.(conversation.id) || false} + onSelect={onConversationSelect} + onCheck={onConversationCheck} + formatDateSmart={formatDateSmart} + t={stableT} + tDialogs={stableTDialogs} + /> ))}
); diff --git a/services/platform/app/features/documents/components/document-upload-dialog.tsx b/services/platform/app/features/documents/components/document-upload-dialog.tsx index b10dc12d0..47d0811d2 100644 --- a/services/platform/app/features/documents/components/document-upload-dialog.tsx +++ b/services/platform/app/features/documents/components/document-upload-dialog.tsx @@ -12,6 +12,7 @@ import { useT } from '@/lib/i18n/client'; import { api } from '@/convex/_generated/api'; import { useDocumentUpload } from '../hooks/use-document-upload'; import { cn } from '@/lib/utils/cn'; +import { formatBytes } from '@/lib/utils/format/number'; import { toast } from '@/app/hooks/use-toast'; import { DOCUMENT_UPLOAD_ACCEPT, DOCUMENT_MAX_FILE_SIZE } from '@/lib/shared/file-types'; @@ -127,12 +128,6 @@ export function DocumentUploadDialog({ [selectedFiles, selectedTeams, uploadFiles], ); - const formatFileSize = (bytes: number) => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - }; - const hasFiles = selectedFiles.length > 0; return ( @@ -193,7 +188,7 @@ export function DocumentUploadDialog({ {file.name} - {formatFileSize(file.size)} + {formatBytes(file.size)}