Files
openchamber/packages/ui/src/components/chat/FileMentionAutocomplete.tsx
T
Nguyễn Ngô ThượngandBohdan Triapitsyn 74fa09225a feat(chat): per-session draft persistence + expandable input focus mode (#480)
* feat(session-folders): drag-to-folder DnD, sort by activity, and UX improvements

- Add DraggableSessionRow wrapping each session row so the whole row is
  draggable; stopPropagation prevents outer group-reorder DnD from firing
- Add DroppableFolderWrapper + SessionFolderDndScope (inner DndContext
  scoped per group) with closestCenter collision detection
- DragOverlay matches exact width/height of dragged row so cursor stays
  aligned
- Folder header highlights (ring + primary colour) when a session hovers
  over it during drag
- + button on folder header opens a dropdown: 'New session' / 'New folder'
- + button on each folder row creates a session scoped to that folder
- Empty folders are no longer auto-deleted (removed .filter(sessionIds.length>0)
  from addSessionToFolder / removeSessionFromFolder / cleanupSessions)
- Sessions inside a folder are sorted by most-recent activity (same
  compareSessionsByPinnedAndTime logic used everywhere else)
- Sort comparator now takes sessionAttentionStates so lastUserMessageAt /
  lastStatusChangeAt is used when newer than session.time.updated; all
  sort call-sites and their useMemo/useCallback deps updated accordingly
- Remove foldersMap from cleanup effect deps to prevent cascade re-renders
  when folders change; read current value via getState() instead

* fix(session-folders): new session is placed into the correct folder

sendMessage() was calling useSessionManagementStore.createSession()
directly, bypassing the targetFolderId logic in useSessionStore.createSession.

Fix: read targetFolderId from draft at the top of the draft branch in
sendMessage, then call addSessionToFolder immediately after the session
is created and before the draft is closed. Also propagate targetFolderId
through openNewSessionDraft options and NewSessionDraftState type.

* feat(session-folders): add sub-folder support (one level deep)

- SessionFolder gains optional parentId field for hierarchy
- createFolder accepts parentId to create sub-folders
- deleteFolder cascades to remove all child sub-folders
- SessionFolderItem renders sub-folders before sessions in body;
  new sub-folder button (RiFolderAddLine) visible at depth 0 only
- renderOneFolderItem in SessionSidebar builds the tree recursively;
  sub-folders are indented via depth prop (ml-3 on root's children)
- Persist/hydrate parentId correctly from localStorage

* feat(session): add delete confirm dialogs and improve subtitle UX

- Add confirmation dialogs before deleting sessions or folders
- Show relative time (e.g., '2h ago', '35min ago') for recent sessions
- Replace +/- diff numbers with file change count (e.g., '3 files changed')
- New folders use default name without forcing rename
- Cleaner, less cluttered session list UI

* fix(session-folders): skip folder cleanup while sessions are loading

Prevents race condition on reload where cleanupSessions() runs before
the server returns the full session list, causing folder-session
assignments to be incorrectly wiped from localStorage.

* feat(chat): per-session draft persistence and expandable input focus mode

- Feature 1 (#478): preserve chat draft per session when switching projects
  - Replace global localStorage key with per-session key (draft_${sessionId})
  - Save draft for old session on switch, restore draft for new session
  - Clear draft on submit

- Feature 2 (#479): expandable chat input focus mode (⌘⇧E / Ctrl+Shift+E)
  - Add isExpandedInput state to useUIStore
  - Register expand_input shortcut (mod+shift+e, customizable)
  - Wire shortcut in useKeyboardShortcuts
  - Overlay portal with full-height textarea, Esc to close, auto-close on submit
  - Expand button with tooltip showing keyboard shortcut hint

* fix(chat): switch to in-place desktop focus mode, caret-anchored autocomplete, and no-focus-jump toggle

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-02-23 00:04:25 +02:00

519 lines
17 KiB
TypeScript

import React from 'react';
import { RiCodeLine, RiFileImageLine, RiFileLine, RiFilePdfLine, RiRefreshLine } from '@remixicon/react';
import { cn, truncatePathMiddle } from '@/lib/utils';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
name: string;
description?: string;
mode?: string | null;
};
export interface FileMentionHandle {
handleKeyDown: (key: string) => void;
}
type AutocompleteTab = 'commands' | 'agents' | 'files';
interface FileMentionAutocompleteProps {
searchQuery: string;
onFileSelect: (file: FileInfo) => void;
onAgentSelect?: (agentName: string) => void;
onClose: () => void;
showTabs?: boolean;
activeTab?: AutocompleteTab;
onTabSelect?: (tab: AutocompleteTab) => void;
style?: React.CSSProperties;
}
export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileMentionAutocompleteProps>(({
searchQuery,
onFileSelect,
onAgentSelect,
onClose,
showTabs,
activeTab = 'files',
onTabSelect,
style,
}, ref) => {
const { currentDirectory } = useDirectoryStore();
const { addServerFile } = useSessionStore();
const { getVisibleAgents } = useConfigStore();
const searchFiles = useFileSearchStore((state) => state.searchFiles);
const debouncedQuery = useDebouncedValue(searchQuery, 180);
const showHidden = useDirectoryShowHidden();
const showGitignored = useFilesViewShowGitignored();
const [files, setFiles] = React.useState<FileInfo[]>([]);
const [agents, setAgents] = React.useState<AgentInfo[]>([]);
const [loading, setLoading] = React.useState(false);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [marqueeWidth, setMarqueeWidth] = React.useState(360);
const [overflowMap, setOverflowMap] = React.useState<Record<number, boolean>>({});
const [marqueeDurations, setMarqueeDurations] = React.useState<Record<number, number>>({});
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const labelRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const ignoreTabClickRef = React.useRef(false);
const normalizedSearchQuery = (searchQuery ?? '').trim();
const visibleAgents = normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2);
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) {
return 0;
}
const c = candidate.toLowerCase();
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i += 1) {
const ch = q[i];
if (!ch || ch === ' ') {
continue;
}
const idx = c.indexOf(ch, lastIndex + 1);
if (idx === -1) {
return null;
}
const gap = idx - lastIndex - 1;
if (gap === 0) {
consecutive += 1;
} else {
consecutive = 0;
}
score += 10;
score += Math.max(0, 18 - idx);
score -= Math.max(0, gap);
if (idx === 0) {
score += 12;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
score += 10;
}
}
score += consecutive > 0 ? 12 : 0;
lastIndex = idx;
}
score += Math.max(0, 24 - Math.round(c.length / 3));
return score;
}, []);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null;
if (!target || !containerRef.current) {
return;
}
if (containerRef.current.contains(target)) {
return;
}
onClose();
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
};
}, [onClose]);
React.useEffect(() => {
if (!currentDirectory) {
setFiles([]);
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
let cancelled = false;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
})
.then((hits) => {
if (cancelled) {
return;
}
const ranked = normalizedQueryLower
? hits
.map((file) => {
const label = file.relativePath || file.name || file.path;
const score = fuzzyScore(normalizedQueryLower, label);
return score === null ? null : { file, score, labelLength: label.length };
})
.filter(Boolean) as Array<{ file: FileInfo; score: number; labelLength: number }>
: hits.map((file) => ({ file, score: 0, labelLength: (file.relativePath || file.name || file.path).length }));
ranked.sort((a, b) => (
b.score - a.score
|| a.labelLength - b.labelLength
|| a.file.path.localeCompare(b.file.path)
));
setFiles(ranked.slice(0, 15).map((entry) => entry.file));
})
.catch(() => {
if (!cancelled) {
setFiles([]);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
const filtered = visibleAgents
.filter((agent) => agent.mode && agent.mode !== 'primary')
.filter((agent) => {
if (!normalizedQuery) return true;
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
return haystack.includes(normalizedQuery);
})
.map((agent) => ({
name: agent.name,
description: agent.description,
mode: agent.mode,
}))
.sort((a, b) => a.name.localeCompare(b.name));
setAgents(filtered);
}, [getVisibleAgents, searchQuery]);
React.useEffect(() => {
setSelectedIndex(0);
setOverflowMap({});
setMarqueeDurations({});
}, [files, visibleAgents.length]);
React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}, [selectedIndex]);
React.useEffect(() => {
let frameId: number | null = null;
const updateOverflow = () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
frameId = requestAnimationFrame(() => {
const next: Record<number, boolean> = {};
const durations: Record<number, number> = {};
labelRefs.current.forEach((node, index) => {
if (!node) {
return;
}
const measureNode = measureRefs.current[index];
const fullWidth = measureNode?.offsetWidth ?? node.scrollWidth;
const overflowPx = Math.max(0, fullWidth - node.clientWidth);
const isOverflowing = overflowPx > 8;
next[index] = isOverflowing;
if (isOverflowing) {
const duration = Math.max(0.6, overflowPx / 110);
durations[index] = duration;
}
});
setOverflowMap(next);
setMarqueeDurations(durations);
});
};
updateOverflow();
window.addEventListener('resize', updateOverflow);
return () => {
if (frameId !== null) {
cancelAnimationFrame(frameId);
}
window.removeEventListener('resize', updateOverflow);
};
}, [files]);
React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex];
if (!labelNode) {
return;
}
const updateWidth = () => {
const width = labelNode.clientWidth;
if (width > 0) {
setMarqueeWidth(width);
}
};
updateWidth();
if (typeof ResizeObserver === 'undefined') {
return;
}
const observer = new ResizeObserver(updateWidth);
observer.observe(labelNode);
return () => {
observer.disconnect();
};
}, [selectedIndex]);
const handleFileSelect = React.useCallback(async (file: FileInfo) => {
await addServerFile(file.path, file.name);
onFileSelect(file);
}, [addServerFile, onFileSelect]);
const handleAgentPick = React.useCallback((agentName: string) => {
onAgentSelect?.(agentName);
}, [onAgentSelect]);
React.useImperativeHandle(ref, () => ({
handleKeyDown: (key: string) => {
if (key === 'Escape') {
onClose();
return;
}
const total = visibleAgents.length + files.length;
if (total === 0) {
return;
}
if (key === 'ArrowDown') {
setSelectedIndex((prev) => (prev + 1) % total);
return;
}
if (key === 'ArrowUp') {
setSelectedIndex((prev) => (prev - 1 + total) % total);
return;
}
if (key === 'Enter' || key === 'Tab') {
const safeIndex = ((selectedIndex % total) + total) % total;
if (safeIndex < visibleAgents.length) {
const agent = visibleAgents[safeIndex];
if (agent) {
handleAgentPick(agent.name);
}
return;
}
const selectedFile = files[safeIndex - visibleAgents.length];
if (selectedFile) {
handleFileSelect(selectedFile);
}
}
}
}), [files, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase();
switch (ext) {
case 'ts':
case 'tsx':
case 'js':
case 'jsx':
return <RiCodeLine className="h-3.5 w-3.5 text-[var(--status-info)]" />;
case 'json':
return <RiCodeLine className="h-3.5 w-3.5 text-[var(--status-warning)]" />;
case 'md':
case 'mdx':
return <RiFileLine className="h-3.5 w-3.5 text-muted-foreground" />;
case 'png':
case 'jpg':
case 'jpeg':
case 'gif':
case 'svg':
return <RiFileImageLine className="h-3.5 w-3.5 text-[var(--status-success)]" />;
default:
return <RiFilePdfLine className="h-3.5 w-3.5 text-muted-foreground" />;
}
};
return (
<div
ref={containerRef}
className="absolute z-[100] min-w-0 w-full max-w-[520px] max-h-64 bg-background border-2 border-border/60 rounded-xl shadow-md bottom-full mb-2 left-0 flex flex-col"
style={style}
>
{showTabs ? (
<div className="px-2 pt-2 pb-1 border-b border-border/60">
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-elevated)] p-1">
{([
{ id: 'commands' as const, label: 'Commands' },
{ id: 'agents' as const, label: 'Agents' },
{ id: 'files' as const, label: 'Files' },
]).map((tab) => (
<button
key={tab.id}
type="button"
className={cn(
'flex-1 px-2.5 py-1 rounded-md typography-meta font-semibold transition-none',
activeTab === tab.id
? 'bg-interactive-selection text-interactive-selection-foreground shadow-sm'
: 'text-muted-foreground hover:bg-interactive-hover/50'
)}
onPointerDown={(event) => {
if (event.pointerType !== 'touch') {
return;
}
event.preventDefault();
event.stopPropagation();
ignoreTabClickRef.current = true;
onTabSelect?.(tab.id);
}}
onClick={() => {
if (ignoreTabClickRef.current) {
ignoreTabClickRef.current = false;
return;
}
onTabSelect?.(tab.id);
}}
>
{tab.label}
</button>
))}
</div>
</div>
) : null}
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="px-0">
{loading ? (
<div className="flex items-center justify-center py-4">
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="pb-2">
{visibleAgents.map((agent, index) => {
const isSelected = selectedIndex === index;
return (
<div
key={`agent-${agent.name}`}
ref={(el) => { itemRefs.current[index] = el; }}
className={cn(
'flex items-start gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg',
isSelected && 'bg-interactive-selection',
)}
onClick={() => handleAgentPick(agent.name)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div>
{agent.description ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div>
</div>
);
})}
{visibleAgents.length > 0 && files.length > 0 && (
<div className="my-1 border-t border-border/60" />
)}
{files.map((file, index) => {
const rowIndex = visibleAgents.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 45 });
const isSelected = selectedIndex === rowIndex;
const isOverflowing = overflowMap[rowIndex] ?? false;
const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6;
const item = (
<div
ref={(el) => { itemRefs.current[rowIndex] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
isSelected && "bg-interactive-selection"
)}
onClick={() => handleFileSelect(file)}
onMouseEnter={() => setSelectedIndex(rowIndex)}
>
{getFileIcon(file)}
<span
ref={(el) => { labelRefs.current[rowIndex] = el; }}
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
style={isSelected ? {
['--file-mention-marquee-width' as string]: `${marqueeWidth}px`,
['--file-mention-marquee-duration' as string]: `${marqueeDuration}s`
} : undefined}
aria-label={relativePath}
>
<span
ref={(el) => { measureRefs.current[rowIndex] = el; }}
className="absolute invisible whitespace-nowrap pointer-events-none"
aria-hidden
>
{relativePath}
</span>
{isOverflowing && isSelected ? (
<span className="inline-block whitespace-nowrap file-mention-marquee">
{relativePath}
</span>
) : (
<span className="block truncate">
{displayPath}
</span>
)}
</span>
</div>
);
return (
<React.Fragment key={file.path}>
{item}
</React.Fragment>
);
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
<div className="px-3 py-1 typography-meta text-muted-foreground">
Type to search more agents
</div>
)}
{files.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
No matches found
</div>
)}
</div>
)}
</ScrollableOverlay>
<div className="px-3 pt-1 pb-1.5 border-t typography-meta text-muted-foreground">
↑↓ navigate Enter select Esc close
</div>
</div>
);
});