diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 615b5098..d695f837 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -242,6 +242,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { // --- New-item animation tracking --- const knownGroupIdsRef = useRef>(new Set()); + const animatedGroupIdsRef = useRef>(new Set()); const isInitialRenderRef = useRef(true); const prevTabIdRef = useRef(effectiveTabId); @@ -249,6 +250,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { if (prevTabIdRef.current !== effectiveTabId) { prevTabIdRef.current = effectiveTabId; knownGroupIdsRef.current.clear(); + animatedGroupIdsRef.current.clear(); isInitialRenderRef.current = true; } @@ -256,6 +258,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { const items = conversation?.items; if (!items || items.length === 0) { knownGroupIdsRef.current.clear(); + animatedGroupIdsRef.current.clear(); isInitialRenderRef.current = true; return new Set(); } @@ -280,6 +283,18 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { return newIds; }, [conversation]); + // Expire animation flags after the CSS animation completes (350ms + buffer). + // This prevents replay when the virtualizer remounts off-screen elements. + useEffect(() => { + if (newGroupIds.size === 0) return; + const timer = setTimeout(() => { + for (const id of newGroupIds) { + animatedGroupIdsRef.current.add(id); + } + }, 400); + return () => clearTimeout(timer); + }, [newGroupIds]); + const rowVirtualizer = useVirtualizer({ count: shouldVirtualize ? (conversation?.items.length ?? 0) : 0, getScrollElement: () => scrollContainerRef.current, @@ -921,7 +936,10 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { isSearchHighlight={isSearchHighlight} isNavigationHighlight={isNavigationHighlight} highlightColor={effectiveHighlightColor} - isNew={newGroupIds.has(item.group.id)} + isNew={ + newGroupIds.has(item.group.id) && + !animatedGroupIdsRef.current.has(item.group.id) + } registerChatItemRef={registerChatItemRef} registerAIGroupRef={registerAIGroupRefCombined} registerToolRef={registerToolRef} @@ -940,7 +958,10 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { isSearchHighlight={isSearchHighlight} isNavigationHighlight={isNavigationHighlight} highlightColor={effectiveHighlightColor} - isNew={newGroupIds.has(item.group.id)} + isNew={ + newGroupIds.has(item.group.id) && + !animatedGroupIdsRef.current.has(item.group.id) + } registerChatItemRef={registerChatItemRef} registerAIGroupRef={registerAIGroupRefCombined} registerToolRef={registerToolRef} diff --git a/src/renderer/components/team/TeamProvisioningBanner.tsx b/src/renderer/components/team/TeamProvisioningBanner.tsx index 7c7b0120..fea60239 100644 --- a/src/renderer/components/team/TeamProvisioningBanner.tsx +++ b/src/renderer/components/team/TeamProvisioningBanner.tsx @@ -43,9 +43,7 @@ export const TeamProvisioningBanner = ({ if (prevRunIdRef.current !== progress?.runId) { prevRunIdRef.current = progress?.runId; - if (dismissed) { - setDismissed(false); - } + setDismissed(false); } // NOTE: we intentionally do NOT auto-dismiss "ready" banners. diff --git a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx index 437e3472..41b14484 100644 --- a/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/GlobalTaskDetailDialog.tsx @@ -65,8 +65,12 @@ export const GlobalTaskDetailDialog = (): React.JSX.Element | null => { ]); const isFullTeamLoaded = selectedTeamName === teamName && !!selectedTeamData; + // Team data is still loading when: + // - selectTeam() hasn't updated selectedTeamName yet (team switch pending) + // - selectedTeamName matches but IPC fetch is still in flight const isThisTeamLoading = - selectedTeamName === teamName && selectedTeamLoading && !selectedTeamData; + selectedTeamName !== teamName || + (selectedTeamName === teamName && selectedTeamLoading && !selectedTeamData); const taskMap = useMemo(() => { const map = new Map(); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index ca91255f..468505b0 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -64,6 +64,9 @@ interface SendMessageDialogProps { onClose: () => void; } +// Sticky action mode — survives dialog close/reopen (component remount) +let stickyActionMode: ActionMode = 'do'; + export const SendMessageDialog = ({ open, teamName, @@ -94,7 +97,13 @@ export const SendMessageDialog = ({ const fileInputRef = useRef(null); const [imageRestrictionError, setImageRestrictionError] = useState(null); const imageRestrictionTimerRef = useRef(0); - const [actionMode, setActionMode] = useState('do'); + const [actionMode, setActionModeState] = useState(stickyActionMode); + const actionModeRef = useRef(stickyActionMode); + const setActionMode = useCallback((mode: ActionMode) => { + actionModeRef.current = mode; + stickyActionMode = mode; + setActionModeState(mode); + }, []); const { attachments, @@ -112,13 +121,20 @@ export const SendMessageDialog = ({ const supportsAttachments = isLeadRecipient && !!isTeamAlive; const canAttach = supportsAttachments && canAddMore; + // Auto-switch to delegate when lead recipient is selected, but don't + // override user's explicit choice on dialog open. + const prevIsLeadRef = useRef(isLeadRecipient); useEffect(() => { + // Skip the initial mount — honour the sticky mode + if (prevIsLeadRef.current === isLeadRecipient) return; + prevIsLeadRef.current = isLeadRecipient; + if (isLeadRecipient) { setActionMode('delegate'); } else { - setActionMode((prev) => (prev === 'delegate' ? 'do' : prev)); + setActionModeState((prev) => (prev === 'delegate' ? 'do' : prev)); } - }, [isLeadRecipient]); + }, [isLeadRecipient, setActionMode]); const [pendingAutoClose, setPendingAutoClose] = useState(false); // Reset form on open transition (avoid setState in render) diff --git a/src/renderer/components/team/messages/MessagesFilterPopover.tsx b/src/renderer/components/team/messages/MessagesFilterPopover.tsx index 1e834ad4..f2b57150 100644 --- a/src/renderer/components/team/messages/MessagesFilterPopover.tsx +++ b/src/renderer/components/team/messages/MessagesFilterPopover.tsx @@ -127,85 +127,94 @@ export const MessagesFilterPopover = ({ Filter messages - -
-

- From -

-
- {fromOptions.length === 0 ? ( -

No data

- ) : ( - fromOptions.map((name) => ( - // eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally - - )) - )} + + {/* Scrollable filter sections */} +
+
+

+ From +

+
+ {fromOptions.length === 0 ? ( +

No data

+ ) : ( + fromOptions.map((name) => ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally + + )) + )} +
+
+
+

+ To +

+
+ {toOptions.length === 0 ? ( +

No data

+ ) : ( + toOptions.map((name) => ( + // eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally + + )) + )} +
-
-

- To -

-
- {toOptions.length === 0 ? ( -

No data

- ) : ( - toOptions.map((name) => ( - // eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally - - )) - )} + + {/* Fixed bottom section */} +
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */} + +
+
+ +
-
-
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */} - -
-
- -
diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index bd673662..49190bc8 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef } from 'react'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { Loader2, UsersRound } from 'lucide-react'; +import { Folder, Loader2, UsersRound } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; @@ -74,10 +74,10 @@ export const MentionSuggestionList = ({ ); } - // Categorize suggestions + // Categorize suggestions (folders are grouped with files) type Section = 'member' | 'team' | 'file'; const getSuggestionSection = (s: MentionSuggestion): Section => { - if (s.type === 'file') return 'file'; + if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -99,7 +99,9 @@ export const MentionSuggestionList = ({ for (const s of suggestions) { const section = getSuggestionSection(s); - const isFile = section === 'file'; + const isFile = s.type === 'file'; + const isFolder = s.type === 'folder'; + const isFileOrFolder = isFile || isFolder; const isTeam = section === 'team'; // Insert section header on transition @@ -109,7 +111,7 @@ export const MentionSuggestionList = ({ } const isSelected = optionIndex === selectedIndex; - const colorSet = isFile + const colorSet = isFileOrFolder ? null : s.color ? getTeamColorSet(s.color) @@ -135,7 +137,9 @@ export const MentionSuggestionList = ({ onSelect(s); }} > - {isFile ? ( + {isFolder ? ( + + ) : isFile ? ( ) : isTeam ? ( )} diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index 0474d281..009b2288 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -377,16 +377,44 @@ export const MentionableTextarea = React.forwardRef { + const textarea = internalRef.current; + const triggerIdx = getTriggerIndex(); + if (!textarea || triggerIdx < 0) return; + + const replaceStart = triggerIdx; + const replaceEnd = triggerIdx + 1 + query.length; + const before = value.slice(0, replaceStart); + const after = value.slice(replaceEnd); + + const displayPath = s.relativePath ?? s.name; + const insertion = `\`${displayPath}\` `; + const newValue = before + insertion + after; + onValueChange(newValue); + dismiss(); + + requestAnimationFrame(() => { + const cursor = before.length + insertion.length; + textarea.setSelectionRange(cursor, cursor); + }); + }, + [getTriggerIndex, query, value, onValueChange, dismiss] + ); + // --- Merged selection handler --- const handleMergedSelect = React.useCallback( (s: MentionSuggestion) => { if (s.type === 'file') { handleFileSelect(s); + } else if (s.type === 'folder') { + handleFolderSelect(s); } else { selectSuggestion(s); } }, - [handleFileSelect, selectSuggestion] + [handleFileSelect, handleFolderSelect, selectSuggestion] ); // Sync backdrop font with textarea computed font to prevent caret drift. diff --git a/src/renderer/hooks/useComposerDraft.ts b/src/renderer/hooks/useComposerDraft.ts index 94b38520..6bb7c17c 100644 --- a/src/renderer/hooks/useComposerDraft.ts +++ b/src/renderer/hooks/useComposerDraft.ts @@ -444,12 +444,11 @@ export function useComposerDraft(teamName: string): UseComposerDraftResult { textRef.current = ''; chipsRef.current = []; attachmentsRef.current = []; - actionModeRef.current = 'do'; + // actionMode is intentionally NOT reset — it is "sticky" across sends setTextState(''); setChipsState([]); setAttachmentsState([]); - setActionModeState('do'); setAttachmentError(null); setIsSaved(false); diff --git a/src/renderer/hooks/useFileSuggestions.ts b/src/renderer/hooks/useFileSuggestions.ts index 9d359c87..605d99db 100644 --- a/src/renderer/hooks/useFileSuggestions.ts +++ b/src/renderer/hooks/useFileSuggestions.ts @@ -1,8 +1,9 @@ /** - * Hook for loading and filtering project files as @-mention suggestions. + * Hook for loading and filtering project files and folders as @-mention suggestions. * * Uses the Quick Open file list API with a 10s TTL cache. - * Returns up to 8 matching files filtered by name or relative path. + * Returns up to 8 matching files/folders filtered by name or relative path. + * Folders are derived from file paths (no extra IPC call needed). */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -17,12 +18,61 @@ import type { MentionSuggestion } from '@renderer/types/mention'; import type { QuickOpenFile } from '@shared/types/editor'; const MAX_FILE_SUGGESTIONS = 8; +const MAX_FOLDER_SUGGESTIONS = 5; export interface UseFileSuggestionsResult { suggestions: MentionSuggestion[]; loading: boolean; } +/** Folder entry derived from file paths. */ +interface DerivedFolder { + /** Folder name (last segment, e.g. "ui") */ + name: string; + /** Relative path with trailing slash, e.g. "src/renderer/components/ui/" */ + relativePath: string; + /** Absolute path */ + absolutePath: string; +} + +/** + * Extracts unique directories from a list of file paths. + * Returns directories sorted by depth (shallower first), then alphabetically. + */ +function extractDirectories(files: QuickOpenFile[], projectPath: string): DerivedFolder[] { + const dirSet = new Set(); + + for (const f of files) { + // Walk up the directory chain from each file's relative path + const parts = f.relativePath.split('/'); + // Remove the file name — keep only directory segments + for (let i = 1; i < parts.length; i++) { + dirSet.add(parts.slice(0, i).join('/')); + } + } + + const folders: DerivedFolder[] = []; + for (const relDir of dirSet) { + const segments = relDir.split('/'); + const name = segments[segments.length - 1]; + folders.push({ + name, + relativePath: relDir + '/', + absolutePath: projectPath + '/' + relDir, + }); + } + + // Sort: shallower first, then alphabetically + folders.sort((a, b) => { + const depthA = a.relativePath.split('/').length; + const depthB = b.relativePath.split('/').length; + if (depthA !== depthB) return depthA - depthB; + return a.relativePath.localeCompare(b.relativePath); + }); + + return folders; +} + /** * Filters files by query (name or relative path) and converts to MentionSuggestion[]. * Exported for testing. @@ -52,7 +102,40 @@ export function filterFileSuggestions(files: QuickOpenFile[], query: string): Me } /** - * Loads project files and returns filtered MentionSuggestion[] with type: 'file'. + * Filters folders by query and converts to MentionSuggestion[]. + * Exported for testing. + */ +export function filterFolderSuggestions( + folders: DerivedFolder[], + query: string +): MentionSuggestion[] { + if (!query || folders.length === 0) return []; + + // Strip trailing slash from query for matching (e.g. "ui/" -> "ui") + const cleanQuery = query.endsWith('/') ? query.slice(0, -1) : query; + const lower = cleanQuery.toLowerCase(); + const results: MentionSuggestion[] = []; + + for (const f of folders) { + if (results.length >= MAX_FOLDER_SUGGESTIONS) break; + + if (f.name.toLowerCase().includes(lower) || f.relativePath.toLowerCase().includes(lower)) { + results.push({ + id: `folder:${f.absolutePath}`, + name: f.name + '/', + subtitle: f.relativePath, + type: 'folder', + filePath: f.absolutePath, + relativePath: f.relativePath, + }); + } + } + + return results; +} + +/** + * Loads project files and returns filtered MentionSuggestion[] with type: 'file' and 'folder'. * * @param projectPath - Project root path (null disables) * @param query - Current @-mention query string @@ -137,11 +220,19 @@ export function useFileSuggestions( return fetchFiles(projectPath); }, [projectPath, fetchTrigger, fetchFiles]); - // Filter by query and convert to MentionSuggestion[] - const suggestions = useMemo( - () => (enabled ? filterFileSuggestions(allFiles, query) : []), - [enabled, query, allFiles] + // Derive folders from file list (memoized) + const allFolders = useMemo( + () => (projectPath ? extractDirectories(allFiles, projectPath) : []), + [allFiles, projectPath] ); + // Filter by query and convert to MentionSuggestion[] — folders first, then files + const suggestions = useMemo(() => { + if (!enabled) return []; + const folders = filterFolderSuggestions(allFolders, query); + const files = filterFileSuggestions(allFiles, query); + return [...folders, ...files]; + }, [enabled, query, allFiles, allFolders]); + return { suggestions, loading }; } diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index b7fc9026..0f402767 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -7,12 +7,12 @@ export interface MentionSuggestion { subtitle?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', or 'file' */ - type?: 'member' | 'team' | 'file'; + /** Suggestion type — 'member' (default), 'team', 'file', or 'folder' */ + type?: 'member' | 'team' | 'file' | 'folder'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; - /** Absolute file path (file suggestions only) */ + /** Absolute file/folder path (file/folder suggestions only) */ filePath?: string; - /** Relative display path (file suggestions only) */ + /** Relative display path (file/folder suggestions only) */ relativePath?: string; } diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts index 2087bb7c..769ef007 100644 --- a/src/renderer/utils/teamMessageFiltering.ts +++ b/src/renderer/utils/teamMessageFiltering.ts @@ -31,11 +31,17 @@ export function filterTeamMessages( const hasFrom = filter.from.size > 0; const hasTo = filter.to.size > 0; - if (hasFrom || hasTo) { + if (hasFrom && hasTo) { list = list.filter((m) => { - const fromMatch = hasFrom && m.from?.trim() && filter.from.has(m.from.trim()); - const toMatch = hasTo && m.to?.trim() && filter.to.has(m.to.trim()); - return fromMatch || toMatch; + const fromMatch = m.from?.trim() && filter.from.has(m.from.trim()); + const toMatch = m.to?.trim() && filter.to.has(m.to.trim()); + return fromMatch && toMatch; + }); + } else if (hasFrom || hasTo) { + list = list.filter((m) => { + if (hasFrom) return m.from?.trim() && filter.from.has(m.from.trim()); + if (hasTo) return m.to?.trim() && filter.to.has(m.to.trim()); + return true; }); }