From 4214427b38615c27b6d7784a744118ac6eb403d6 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 10 Mar 2026 20:44:04 +0200 Subject: [PATCH] fix: reset notification dismissal on team relaunch When a user closes the 'Team launched' notification and then launches the team again, the notification now correctly shows up again. Previously, the dismissed state would persist across launches. The fix unconditionally resets dismissed to false whenever a new provisioning runId is detected, ensuring the banner appears for each new launch attempt. --- src/renderer/components/chat/ChatHistory.tsx | 25 ++- .../team/TeamProvisioningBanner.tsx | 4 +- .../team/dialogs/GlobalTaskDetailDialog.tsx | 6 +- .../team/dialogs/SendMessageDialog.tsx | 22 ++- .../team/messages/MessagesFilterPopover.tsx | 161 +++++++++--------- .../components/ui/MentionSuggestionList.tsx | 18 +- .../components/ui/MentionableTextarea.tsx | 30 +++- src/renderer/hooks/useComposerDraft.ts | 3 +- src/renderer/hooks/useFileSuggestions.ts | 105 +++++++++++- src/renderer/types/mention.ts | 8 +- src/renderer/utils/teamMessageFiltering.ts | 14 +- 11 files changed, 286 insertions(+), 110 deletions(-) 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; }); }