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.
This commit is contained in:
iliya 2026-03-10 20:44:04 +02:00
parent a4210936f9
commit 4214427b38
11 changed files with 286 additions and 110 deletions

View file

@ -242,6 +242,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
// --- New-item animation tracking ---
const knownGroupIdsRef = useRef<Set<string>>(new Set());
const animatedGroupIdsRef = useRef<Set<string>>(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<string>();
}
@ -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}

View file

@ -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.

View file

@ -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<string, TeamTaskWithKanban>();

View file

@ -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<HTMLInputElement>(null);
const [imageRestrictionError, setImageRestrictionError] = useState<string | null>(null);
const imageRestrictionTimerRef = useRef(0);
const [actionMode, setActionMode] = useState<ActionMode>('do');
const [actionMode, setActionModeState] = useState<ActionMode>(stickyActionMode);
const actionModeRef = useRef<ActionMode>(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)

View file

@ -127,85 +127,94 @@ export const MessagesFilterPopover = ({
</TooltipTrigger>
<TooltipContent side="bottom">Filter messages</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-72 p-0">
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
From
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{fromOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
fromOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox
checked={draft.from.has(name)}
onCheckedChange={() => toggleFrom(name)}
/>
<MemberBadge
name={name}
color={colorMap.get(name)}
size="sm"
hideAvatar={name === 'user'}
/>
</label>
))
)}
<PopoverContent align="end" className="flex max-h-[70vh] w-72 flex-col p-0">
{/* Scrollable filter sections */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
From
</p>
<div className="space-y-1">
{fromOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
fromOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox
checked={draft.from.has(name)}
onCheckedChange={() => toggleFrom(name)}
/>
<MemberBadge
name={name}
color={colorMap.get(name)}
size="sm"
hideAvatar={name === 'user'}
/>
</label>
))
)}
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
To
</p>
<div className="space-y-1">
{toOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
toOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
<MemberBadge
name={name}
color={colorMap.get(name)}
size="sm"
hideAvatar={name === 'user'}
/>
</label>
))
)}
</div>
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-[var(--color-text-muted)]">
To
</p>
<div className="max-h-40 space-y-1 overflow-y-auto">
{toOptions.length === 0 ? (
<p className="text-xs italic text-[var(--color-text-muted)]">No data</p>
) : (
toOptions.map((name) => (
// eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox which renders native input internally
<label
key={name}
className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]"
>
<Checkbox checked={draft.to.has(name)} onCheckedChange={() => toggleTo(name)} />
<MemberBadge
name={name}
color={colorMap.get(name)}
size="sm"
hideAvatar={name === 'user'}
/>
</label>
))
)}
{/* Fixed bottom section */}
<div className="shrink-0 border-t border-[var(--color-border)]">
<div className="border-b border-[var(--color-border)] p-3">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={draft.showNoise}
onCheckedChange={() =>
setDraft((prev) => ({ ...prev, showNoise: !prev.showNoise }))
}
/>
<span>Show status updates (idle/shutdown)</span>
</label>
</div>
<div className="flex justify-between gap-2 p-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={draftCount === 0 && !draft.showNoise}
onClick={handleReset}
>
Reset
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
Save
</Button>
</div>
</div>
<div className="border-b border-[var(--color-border)] p-3">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control -- wraps Radix Checkbox */}
<label className="flex cursor-pointer items-center gap-2 rounded-md px-1 py-0.5 text-xs text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]">
<Checkbox
checked={draft.showNoise}
onCheckedChange={() => setDraft((prev) => ({ ...prev, showNoise: !prev.showNoise }))}
/>
<span>Show status updates (idle/shutdown)</span>
</label>
</div>
<div className="flex justify-between gap-2 p-2">
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
disabled={draftCount === 0 && !draft.showNoise}
onClick={handleReset}
>
Reset
</Button>
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={handleSave}>
Save
</Button>
</div>
</PopoverContent>
</Popover>

View file

@ -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 ? (
<Folder size={14} className="shrink-0 text-[var(--color-text-muted)]" />
) : isFile ? (
<FileIcon fileName={s.name} className="size-3.5" />
) : isTeam ? (
<UsersRound
@ -150,7 +154,7 @@ export const MentionSuggestionList = ({
/>
)}
<span
className={isFile ? 'truncate' : 'font-medium'}
className={isFileOrFolder ? 'truncate' : 'font-medium'}
style={colorSet ? { color: colorSet.text } : undefined}
>
<HighlightedName name={s.name} query={query} />

View file

@ -377,16 +377,44 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[getTriggerIndex, query, value, chips, onValueChange, onFileChipInsert, onChipRemove, dismiss]
);
// --- Folder selection handler (inserts folder path with trailing slash) ---
const handleFolderSelect = React.useCallback(
(s: MentionSuggestion) => {
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.

View file

@ -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);

View file

@ -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<string>();
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 };
}

View file

@ -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;
}

View file

@ -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;
});
}