feat: massive chat reliability + UX pass (web/desktop/mobile/vscode) (#593)
## Added Features - Add VS Code save-as-image flow for assistant messages via webview bridge + native save dialog. - Add hourly desktop update checks after startup. - Add new tool output display mode: `Changes` (auto-expand edit/write/patch only; keep activity expanded; mode guidance text). - Add GitHub PR attachment flow in chat input with PR picker + attached PR chip/details. - Add mobile overlay presentation for GitHub Issue and PR pickers (shared with desktop picker content). ## Fixes - Save-gate project icon updates until explicit Save; allow icon removal with same save-gated behavior. - Restore clickable chat action buttons in sticky header mode (desktop + Firefox hit-target issue). - Clamp sticky user messages to bounded chat height and allow internal scrolling. - Prevent drawer context crash during iPad/tablet orientation switching. - Improve text-selection action menu placement on narrow screens. - Move assistant message time into clock tooltip; keep duration display clean. - Hide `Link GitHub Issue` row in VS Code chat input area (GitHub flow is not yet ready there). - Remove laggy close animation in text-selection popover; keep open motion/positioning behavior. - Fetch branches when picker opens and cache empty; show loading state instead of false “No branches found”. - Fix share-image export metadata rendering (theme background resolution, timestamp rendering, footer alignment). - Scope MCP services status/toggles to active directory to avoid cross-project leakage. - Improve long user-message clamp behavior (40% cap variant, hidden scrollbar, scroll shadows, expansion detection). - Fix desktop `Check for Updates` menu handler; prevent duplicate checks; show clear success/error toasts. - Stabilize long user-message scrolling behavior (follow-up hardening). - Avoid premature web update failure on slower servers. - Restore user message image previews + fullscreen gallery navigation payload. - Repair desktop chat drag-and-drop image attachments when native drop coords are missing. - Move GitHub issue linking entry into Add attachment menu. - Align header context usage percentage visuals with context panel. - Align `@` file search with active project in all runtimes. - Route `@` file discovery through OpenCode SDK `find.files`; remove legacy `/api/fs/search` reliance. - Make chat `@` mention behavior consistent with files-style behavior. - Keep status-row todos in stable order after status changes; add compact status icons; replace noisy priority labels. ## Refactors / UX Consistency - Simplify chat attachment model and remove project file picker path. - Keep composer focused on `@` mention file flow. - Use direct `Attach files` action in VS Code instead of attachment dropdown path. - Unify issue/PR picker behavior between desktop and mobile overlays.
This commit is contained in:
committed by
GitHub
parent
ca18b8be0f
commit
79143bff4c
@@ -327,6 +327,33 @@ export const ChatContainer: React.FC = () => {
|
||||
trimToViewportWindow,
|
||||
});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateChatScrollHeight = () => {
|
||||
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
|
||||
};
|
||||
|
||||
updateChatScrollHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
window.addEventListener('resize', updateChatScrollHeight);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateChatScrollHeight);
|
||||
};
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateChatScrollHeight);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
cancelTurnBackfill();
|
||||
if (!currentSessionId) {
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
RiCloseLine,
|
||||
RiCommandLine,
|
||||
RiExternalLinkLine,
|
||||
RiFileUploadLine,
|
||||
RiFullscreenLine,
|
||||
RiGitPullRequestLine,
|
||||
RiGithubLine,
|
||||
RiSendPlane2Line,
|
||||
} from '@remixicon/react';
|
||||
@@ -26,7 +26,6 @@ import { FileMentionAutocomplete, type FileMentionHandle } from './FileMentionAu
|
||||
import { CommandAutocomplete, type CommandAutocompleteHandle } from './CommandAutocomplete';
|
||||
import { SkillAutocomplete, type SkillAutocompleteHandle } from './SkillAutocomplete';
|
||||
import { cn, isMacOS } from '@/lib/utils';
|
||||
import { ServerFilePicker } from './ServerFilePicker';
|
||||
import { ModelControls } from './ModelControls';
|
||||
import { UnifiedControlsDrawer } from './UnifiedControlsDrawer';
|
||||
import { parseAgentMentions } from '@/lib/messages/agentMentions';
|
||||
@@ -39,7 +38,7 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
|
||||
import { toast } from '@/components/ui';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { useMessageStore } from '@/stores/messageStore';
|
||||
import { isDesktopLocalOriginActive, isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isTauriShell, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { isIMECompositionEvent } from '@/lib/ime';
|
||||
import { StopIcon } from '@/components/icons/StopIcon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -52,9 +51,13 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog';
|
||||
import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
|
||||
const MAX_VISIBLE_TEXTAREA_LINES = 8;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const FILE_MENTION_TOKEN = /^@[^\s]+$/;
|
||||
|
||||
interface ChatInputProps {
|
||||
onOpenSettings?: () => void;
|
||||
@@ -127,6 +130,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const dropZoneRef = React.useRef<HTMLDivElement>(null);
|
||||
const canAcceptDropRef = React.useRef(false);
|
||||
const nativeDragInsideDropZoneRef = React.useRef(false);
|
||||
const mentionRef = React.useRef<FileMentionHandle>(null);
|
||||
const commandRef = React.useRef<CommandAutocompleteHandle>(null);
|
||||
const skillRef = React.useRef<SkillAutocompleteHandle>(null);
|
||||
@@ -142,7 +146,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const clearAbortPrompt = useSessionStore((state) => state.clearAbortPrompt);
|
||||
const attachedFiles = useSessionStore((state) => state.attachedFiles);
|
||||
const addAttachedFile = useSessionStore((state) => state.addAttachedFile);
|
||||
const addServerFile = useSessionStore((state) => state.addServerFile);
|
||||
const clearAttachedFiles = useSessionStore((state) => state.clearAttachedFiles);
|
||||
const saveSessionAgentSelection = useSessionStore((state) => state.saveSessionAgentSelection);
|
||||
const consumePendingInputText = useSessionStore((state) => state.consumePendingInputText);
|
||||
@@ -155,14 +158,177 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const { isMobile, inputBarOffset, isKeyboardOpen, setTimelineDialogOpen, cornerRadius, persistChatDraft, isExpandedInput, setExpandedInput } = useUIStore();
|
||||
const { working } = useAssistantStatus();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const chatSearchDirectory = useChatSearchDirectory();
|
||||
const [showAbortStatus, setShowAbortStatus] = React.useState(false);
|
||||
const [textareaScrollTop, setTextareaScrollTop] = React.useState(0);
|
||||
|
||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||
|
||||
const sendableAttachedFiles = React.useMemo(
|
||||
() => attachedFiles.filter((file) => file.source !== 'server'),
|
||||
[attachedFiles],
|
||||
);
|
||||
|
||||
const hasInlineMentionForHighlight = React.useMemo(() => {
|
||||
if (!message || !message.includes('@') || inputMode === 'shell') {
|
||||
return false;
|
||||
}
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
const mentionRegex = /@([^\s]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = mentionRegex.exec(message)) !== null) {
|
||||
const offset = match.index;
|
||||
const charBefore = offset > 0 ? message[offset - 1] : null;
|
||||
if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
|
||||
continue;
|
||||
}
|
||||
const mentionPath = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
|
||||
if (!mentionPath) {
|
||||
continue;
|
||||
}
|
||||
if (knownAgentNames.has(mentionPath.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [agents, inputMode, message]);
|
||||
|
||||
const highlightedComposerContent = React.useMemo(() => {
|
||||
if (!hasInlineMentionForHighlight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: Array<{ text: string; mentionKind: 'none' | 'file' | 'agent' }> = [];
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
const mentionRegex = /@([^\s]+)/g;
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionRegex.exec(message)) !== null) {
|
||||
const full = match[0];
|
||||
const mention = String(match[1] || '').trim().replace(/[),.;:!?`"'>]+$/g, '');
|
||||
const start = match.index;
|
||||
const end = start + full.length;
|
||||
const charBefore = start > 0 ? message[start - 1] : null;
|
||||
const isBoundary = !charBefore || /(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore);
|
||||
const isAgentMention = isBoundary && mention.length > 0 && knownAgentNames.has(mention.toLowerCase());
|
||||
const isFileMention = isBoundary
|
||||
&& mention.length > 0
|
||||
&& !knownAgentNames.has(mention.toLowerCase())
|
||||
&& (mention.includes('/') || mention.includes('\\') || mention.includes('.'));
|
||||
|
||||
if (start > lastIndex) {
|
||||
parts.push({ text: message.slice(lastIndex, start), mentionKind: 'none' });
|
||||
}
|
||||
parts.push({
|
||||
text: full,
|
||||
mentionKind: isFileMention ? 'file' : isAgentMention ? 'agent' : 'none',
|
||||
});
|
||||
lastIndex = end;
|
||||
}
|
||||
|
||||
if (lastIndex < message.length) {
|
||||
parts.push({ text: message.slice(lastIndex), mentionKind: 'none' });
|
||||
}
|
||||
|
||||
return parts;
|
||||
}, [agents, hasInlineMentionForHighlight, message]);
|
||||
|
||||
const sanitizeAttachmentsForSend = React.useCallback(
|
||||
(files: AttachedFile[] | undefined): AttachedFile[] => (files ?? [])
|
||||
.filter((file) => file.source !== 'server')
|
||||
.map((file) => ({ ...file })),
|
||||
[],
|
||||
);
|
||||
|
||||
const extractInlineFileMentions = React.useCallback((rawText: string): { sanitizedText: string; attachments: AttachedFile[] } => {
|
||||
if (!rawText || !rawText.includes('@')) {
|
||||
return { sanitizedText: rawText, attachments: [] };
|
||||
}
|
||||
|
||||
const clientDirectory = opencodeClient.getDirectory() || '';
|
||||
const root = (chatSearchDirectory || clientDirectory).replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
const knownAgentNames = new Set(agents.map((agent) => agent.name.toLowerCase()));
|
||||
const seenPaths = new Set<string>();
|
||||
const attachments: AttachedFile[] = [];
|
||||
|
||||
const mentionRegex = /@([^\s]+)/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = mentionRegex.exec(rawText)) !== null) {
|
||||
const rawMentionPath = match[1];
|
||||
const offset = match.index;
|
||||
const original = rawText;
|
||||
const charBefore = offset > 0 ? original[offset - 1] : null;
|
||||
if (charBefore && !/(\s|\(|\)|\[|\]|\{|\}|"|'|`|,|\.|;|:)/.test(charBefore)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mentionPath = String(rawMentionPath || '')
|
||||
.trim()
|
||||
.replace(/^[`"'<(]+/, '')
|
||||
.replace(/[),.;:!?`"'>]+$/g, '');
|
||||
if (!mentionPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (knownAgentNames.has(mentionPath.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const looksLikeFilePath = mentionPath.includes('/') || mentionPath.includes('\\') || mentionPath.includes('.');
|
||||
if (!looksLikeFilePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedMentionPath = mentionPath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
||||
if (!normalizedMentionPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serverPath = mentionPath.startsWith('/')
|
||||
? mentionPath.replace(/\\/g, '/')
|
||||
: root
|
||||
? `${root}/${normalizedMentionPath}`
|
||||
: null;
|
||||
|
||||
if (!serverPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedServerPath = serverPath.replace(/\/+/g, '/');
|
||||
if (seenPaths.has(normalizedServerPath)) {
|
||||
continue;
|
||||
}
|
||||
seenPaths.add(normalizedServerPath);
|
||||
|
||||
const filename = normalizedMentionPath.split('/').filter(Boolean).pop() || normalizedMentionPath;
|
||||
attachments.push({
|
||||
id: `inline-server-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
file: new File([], filename, { type: 'text/plain' }),
|
||||
filename,
|
||||
mimeType: 'text/plain',
|
||||
size: 0,
|
||||
dataUrl: normalizedServerPath,
|
||||
source: 'server',
|
||||
serverPath: normalizedServerPath,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sanitizedText: rawText,
|
||||
attachments,
|
||||
};
|
||||
}, [agents, chatSearchDirectory]);
|
||||
const [autocompleteOverlayPosition, setAutocompleteOverlayPosition] = React.useState<AutocompleteOverlayPosition | null>(null);
|
||||
const abortTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevWasAbortedRef = React.useRef(false);
|
||||
|
||||
// Issue linking state (for draft sessions)
|
||||
// Issue linking state
|
||||
const [issuePickerOpen, setIssuePickerOpen] = React.useState(false);
|
||||
const [prPickerOpen, setPrPickerOpen] = React.useState(false);
|
||||
const [linkedIssue, setLinkedIssue] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
@@ -170,6 +336,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
const [linkedPr, setLinkedPr] = React.useState<{
|
||||
number: number;
|
||||
title: string;
|
||||
url: string;
|
||||
head: string;
|
||||
base: string;
|
||||
includeDiff: boolean;
|
||||
instructionsText: string;
|
||||
contextText: string;
|
||||
author?: { login: string; avatarUrl?: string };
|
||||
} | null>(null);
|
||||
|
||||
// Message queue
|
||||
const queueModeEnabled = useMessageQueueStore((state) => state.queueModeEnabled);
|
||||
@@ -429,7 +606,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}, [pendingInputText, consumePendingInputText]);
|
||||
|
||||
const hasContent = message.trim() || attachedFiles.length > 0 || hasDrafts;
|
||||
const hasContent = message.trim() || sendableAttachedFiles.length > 0 || hasDrafts;
|
||||
const hasQueuedMessages = queuedMessages.length > 0;
|
||||
const canSend = hasContent || hasQueuedMessages;
|
||||
|
||||
@@ -451,7 +628,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
if (drafts.length > 0) {
|
||||
messageToQueue = appendInlineComments(messageToQueue, drafts);
|
||||
}
|
||||
const attachmentsToQueue = attachedFiles.map((file) => ({ ...file }));
|
||||
const attachmentsToQueue = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
||||
|
||||
addToQueue(currentSessionId, {
|
||||
content: messageToQueue,
|
||||
@@ -467,7 +644,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
if (!isMobile) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [hasContent, currentSessionId, message, attachedFiles, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
|
||||
}, [hasContent, currentSessionId, message, sendableAttachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, consumeDrafts]);
|
||||
|
||||
const handleSubmit = async (options?: SubmitOptions) => {
|
||||
const queuedOnly = options?.queuedOnly ?? false;
|
||||
@@ -499,6 +676,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
for (let i = 0; i < queuedMessages.length; i++) {
|
||||
const queuedMsg = queuedMessages[i];
|
||||
const { sanitizedText, mention } = parseAgentMentions(queuedMsg.content, agents);
|
||||
const { sanitizedText: queuedText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
||||
|
||||
// Use agent mention from first message that has one
|
||||
if (!agentMentionName && mention?.name) {
|
||||
@@ -507,13 +685,17 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
if (i === 0) {
|
||||
// First queued message becomes primary
|
||||
primaryText = sanitizedText;
|
||||
primaryAttachments = queuedMsg.attachments ?? [];
|
||||
primaryText = queuedText;
|
||||
primaryAttachments = [
|
||||
...sanitizeAttachmentsForSend(queuedMsg.attachments),
|
||||
...mentionAttachments,
|
||||
];
|
||||
} else {
|
||||
// Subsequent queued messages become additional parts
|
||||
const queuedAttachments = sanitizeAttachmentsForSend(queuedMsg.attachments);
|
||||
additionalParts.push({
|
||||
text: sanitizedText,
|
||||
attachments: queuedMsg.attachments,
|
||||
text: queuedText,
|
||||
attachments: [...queuedAttachments, ...mentionAttachments],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -522,7 +704,8 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
if (!queuedOnly && hasContent) {
|
||||
const messageToSend = message.replace(/^\n+|\n+$/g, '');
|
||||
const { sanitizedText, mention } = parseAgentMentions(messageToSend, agents);
|
||||
const attachmentsToSend = attachedFiles.map((file) => ({ ...file }));
|
||||
const { sanitizedText: messageText, attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText);
|
||||
const attachmentsToSend = sanitizeAttachmentsForSend(sendableAttachedFiles);
|
||||
|
||||
if (!agentMentionName && mention?.name) {
|
||||
agentMentionName = mention.name;
|
||||
@@ -530,13 +713,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
if (queuedMessages.length === 0) {
|
||||
// No queue - current input is primary
|
||||
primaryText = sanitizedText;
|
||||
primaryAttachments = attachmentsToSend;
|
||||
primaryText = messageText;
|
||||
primaryAttachments = [...attachmentsToSend, ...mentionAttachments];
|
||||
} else {
|
||||
// Has queue - current input is additional part
|
||||
additionalParts.push({
|
||||
text: sanitizedText,
|
||||
attachments: attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
|
||||
text: messageText,
|
||||
attachments: [...attachmentsToSend, ...mentionAttachments],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -570,13 +753,24 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
// Add linked issue as synthetic part (only the parts with synthetic: true)
|
||||
// The text part (synthetic: false) is completely dropped per requirements
|
||||
if (linkedIssue && newSessionDraftOpen) {
|
||||
if (linkedIssue) {
|
||||
additionalParts.push({
|
||||
text: linkedIssue.contextText,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (linkedPr) {
|
||||
additionalParts.push({
|
||||
text: linkedPr.instructionsText,
|
||||
synthetic: true,
|
||||
});
|
||||
additionalParts.push({
|
||||
text: linkedPr.contextText,
|
||||
synthetic: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!primaryText && additionalParts.length === 0) return;
|
||||
|
||||
// Clear queue and input
|
||||
@@ -649,10 +843,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
currentVariant,
|
||||
inputMode
|
||||
).then(() => {
|
||||
// Clear linked issue after successful message send in draft mode
|
||||
if (linkedIssue && newSessionDraftOpen) {
|
||||
// Clear linked issue after successful message send
|
||||
if (linkedIssue) {
|
||||
setLinkedIssue(null);
|
||||
}
|
||||
if (linkedPr) {
|
||||
setLinkedPr(null);
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
const rawMessage =
|
||||
error instanceof Error
|
||||
@@ -732,6 +929,48 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
return;
|
||||
}
|
||||
|
||||
if ((e.key === 'Backspace' || e.key === 'Delete') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const textarea = textareaRef.current;
|
||||
const selectionStart = textarea?.selectionStart ?? message.length;
|
||||
const selectionEnd = textarea?.selectionEnd ?? message.length;
|
||||
const hasCollapsedSelection = selectionStart === selectionEnd;
|
||||
|
||||
if (hasCollapsedSelection) {
|
||||
const probeIndex = e.key === 'Backspace' ? selectionStart - 1 : selectionStart;
|
||||
if (probeIndex >= 0 && probeIndex < message.length) {
|
||||
let tokenStart = probeIndex;
|
||||
while (tokenStart > 0 && !/\s/.test(message[tokenStart - 1])) {
|
||||
tokenStart -= 1;
|
||||
}
|
||||
|
||||
let tokenEnd = probeIndex + 1;
|
||||
while (tokenEnd < message.length && !/\s/.test(message[tokenEnd])) {
|
||||
tokenEnd += 1;
|
||||
}
|
||||
|
||||
const token = message.slice(tokenStart, tokenEnd);
|
||||
const looksLikeFileMention = FILE_MENTION_TOKEN.test(token)
|
||||
&& (token.includes('/') || token.includes('\\') || token.includes('.'));
|
||||
|
||||
if (looksLikeFileMention) {
|
||||
const removeUntil = message[tokenEnd] === ' ' ? tokenEnd + 1 : tokenEnd;
|
||||
const nextMessage = `${message.slice(0, tokenStart)}${message.slice(removeUntil)}`;
|
||||
e.preventDefault();
|
||||
setMessage(nextMessage);
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = tokenStart;
|
||||
textareaRef.current.selectionEnd = tokenStart;
|
||||
}
|
||||
adjustTextareaHeight();
|
||||
});
|
||||
updateAutocompleteState(nextMessage, tokenStart);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCommandAutocomplete && commandRef.current) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Escape' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
@@ -1303,25 +1542,38 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
|
||||
|
||||
const handleFileSelect = (file: { name: string; path: string }) => {
|
||||
const handleFileSelect = (file: { name: string; path: string; relativePath?: string }) => {
|
||||
|
||||
const cursorPosition = textareaRef.current?.selectionStart || 0;
|
||||
const textBeforeCursor = message.substring(0, cursorPosition);
|
||||
const lastAtSymbol = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
const mentionPath = (file.relativePath && file.relativePath.trim().length > 0)
|
||||
? file.relativePath.trim()
|
||||
: (toProjectRelativeMentionPath(file.path) || file.name);
|
||||
|
||||
if (lastAtSymbol !== -1) {
|
||||
const newMessage =
|
||||
message.substring(0, lastAtSymbol) +
|
||||
file.name +
|
||||
`@${mentionPath} ` +
|
||||
message.substring(cursorPosition);
|
||||
setMessage(newMessage);
|
||||
const nextCursor = lastAtSymbol + mentionPath.length + 2;
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = nextCursor;
|
||||
textareaRef.current.selectionEnd = nextCursor;
|
||||
}
|
||||
adjustTextareaHeight();
|
||||
updateAutocompleteState(newMessage, nextCursor);
|
||||
});
|
||||
} else if (textareaRef.current) {
|
||||
const newMessage =
|
||||
message.substring(0, cursorPosition) +
|
||||
`@${file.name} ` +
|
||||
`@${mentionPath} ` +
|
||||
message.substring(cursorPosition);
|
||||
setMessage(newMessage);
|
||||
const nextCursor = cursorPosition + file.name.length + 2;
|
||||
const nextCursor = cursorPosition + mentionPath.length + 2;
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = nextCursor;
|
||||
@@ -1600,6 +1852,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toProjectRelativeMentionPath = React.useCallback((absolutePath: string): string => {
|
||||
const normalizedAbsolutePath = absolutePath.replace(/\\/g, '/').trim();
|
||||
const normalizedRoot = (chatSearchDirectory || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!normalizedRoot) {
|
||||
return normalizedAbsolutePath;
|
||||
}
|
||||
if (normalizedAbsolutePath === normalizedRoot) {
|
||||
return normalizedAbsolutePath;
|
||||
}
|
||||
const rootWithSlash = `${normalizedRoot}/`;
|
||||
if (normalizedAbsolutePath.startsWith(rootWithSlash)) {
|
||||
return normalizedAbsolutePath.slice(rootWithSlash.length);
|
||||
}
|
||||
return normalizedAbsolutePath;
|
||||
}, [chatSearchDirectory]);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
if (!hasDraggedFiles(e.dataTransfer)) {
|
||||
return;
|
||||
@@ -1697,7 +1965,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
|
||||
// Check if drop is inside the chat input area
|
||||
const zone = dropZoneRef.current;
|
||||
let inZone = false;
|
||||
let inZone: boolean | null = null;
|
||||
if (zone && typeof x === 'number' && typeof y === 'number') {
|
||||
const rect = zone.getBoundingClientRect();
|
||||
inZone = x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
@@ -1710,16 +1978,22 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
}
|
||||
|
||||
if (type === 'enter' || type === 'over') {
|
||||
setIsDragging(inZone);
|
||||
if (inZone !== null) {
|
||||
nativeDragInsideDropZoneRef.current = inZone;
|
||||
}
|
||||
setIsDragging(nativeDragInsideDropZoneRef.current);
|
||||
return;
|
||||
}
|
||||
if (type === 'leave') {
|
||||
nativeDragInsideDropZoneRef.current = false;
|
||||
setIsDragging(false);
|
||||
return;
|
||||
}
|
||||
if (type === 'drop') {
|
||||
const shouldHandleDrop = inZone ?? nativeDragInsideDropZoneRef.current;
|
||||
nativeDragInsideDropZoneRef.current = false;
|
||||
setIsDragging(false);
|
||||
if (!inZone) return;
|
||||
if (!shouldHandleDrop) return;
|
||||
|
||||
const paths = Array.isArray(typed.paths)
|
||||
? typed.paths.filter((p): p is string => typeof p === 'string')
|
||||
@@ -1734,9 +2008,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
const fileName = normalizedPath.split(/[\\/]/).pop() || normalizedPath;
|
||||
let file: File;
|
||||
|
||||
// In desktop shell on remote origin, local file paths are not readable via /api/fs/raw.
|
||||
// Read bytes from local machine via Tauri command.
|
||||
if (isTauriShell() && !isDesktopLocalOriginActive()) {
|
||||
// In Tauri shell, dropped paths are local machine paths.
|
||||
// Read bytes via native command to avoid workspace-bound /api/fs/raw restrictions.
|
||||
if (isTauriShell()) {
|
||||
const { invoke } = await import('@tauri-apps/api/core');
|
||||
const result = await invoke<{ mime: string; base64: string }>('desktop_read_file', { path: normalizedPath });
|
||||
const byteCharacters = atob(result.base64);
|
||||
@@ -1788,30 +2062,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
};
|
||||
}, [addAttachedFile, normalizeDroppedPath]);
|
||||
|
||||
const handleServerFilesSelected = React.useCallback(async (files: Array<{ path: string; name: string }>) => {
|
||||
let attachedCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const sizeBefore = useSessionStore.getState().attachedFiles.length;
|
||||
try {
|
||||
await addServerFile(file.path, file.name);
|
||||
const sizeAfter = useSessionStore.getState().attachedFiles.length;
|
||||
if (sizeAfter > sizeBefore) {
|
||||
attachedCount += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Server file attach failed', error);
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to attach file');
|
||||
}
|
||||
}
|
||||
|
||||
if (attachedCount > 0) {
|
||||
toast.success(`Attached ${attachedCount} file${attachedCount > 1 ? 's' : ''}`);
|
||||
}
|
||||
}, [addServerFile]);
|
||||
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [projectFilePickerOpen, setProjectFilePickerOpen] = React.useState(false);
|
||||
|
||||
const attachFiles = React.useCallback(async (files: FileList | File[]) => {
|
||||
let attachedCount = 0;
|
||||
@@ -1992,55 +2243,60 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
/>
|
||||
|
||||
<div className="relative inline-flex">
|
||||
<ServerFilePicker
|
||||
onFilesSelected={handleServerFilesSelected}
|
||||
multiSelect
|
||||
presentation={isMobile ? 'modal' : 'dropdown'}
|
||||
open={projectFilePickerOpen}
|
||||
onOpenChange={setProjectFilePickerOpen}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 opacity-0 pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
</ServerFilePicker>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
title="Add attachment"
|
||||
aria-label="Add attachment"
|
||||
>
|
||||
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(() => handlePickLocalFiles());
|
||||
}}
|
||||
>
|
||||
<RiAttachment2 />
|
||||
Attach files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(() => {
|
||||
setProjectFilePickerOpen(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiFileUploadLine />
|
||||
Attach from project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{isVSCode ? (
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => handlePickLocalFiles()}
|
||||
title="Attach files"
|
||||
aria-label="Attach files"
|
||||
>
|
||||
<RiAttachment2 className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
title="Add attachment"
|
||||
aria-label="Add attachment"
|
||||
>
|
||||
<RiAddCircleLine className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(() => handlePickLocalFiles());
|
||||
}}
|
||||
>
|
||||
<RiAttachment2 />
|
||||
Attach files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(() => {
|
||||
setIssuePickerOpen(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiGithubLine />
|
||||
Link GitHub Issue
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
requestAnimationFrame(() => {
|
||||
setPrPickerOpen(true);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<RiGitPullRequestLine />
|
||||
Link GitHub PR
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -2165,67 +2421,104 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Issue Button - only in draft mode */}
|
||||
{newSessionDraftOpen && (
|
||||
{/* Linked Issue row */}
|
||||
{linkedIssue && !isVSCode && (
|
||||
<div className="pb-2 w-full px-1">
|
||||
{linkedIssue ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIssuePickerOpen(true)}
|
||||
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||
>
|
||||
{linkedIssue.author?.avatarUrl && (
|
||||
<img
|
||||
src={linkedIssue.author.avatarUrl}
|
||||
alt={linkedIssue.author.login}
|
||||
className="h-5 w-5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
#{linkedIssue.number}
|
||||
{linkedIssue.author && (
|
||||
<span className="ml-1">by {linkedIssue.author.login}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground truncate">
|
||||
{linkedIssue.title}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<a
|
||||
href={linkedIssue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
||||
aria-label="Open issue in browser"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLinkedIssue(null);
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
aria-label="Remove linked issue"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIssuePickerOpen(true)}
|
||||
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||
>
|
||||
<RiGithubLine
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
style={{ color: currentTheme?.colors?.status?.success }}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIssuePickerOpen(true)}
|
||||
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||
>
|
||||
{linkedIssue.author?.avatarUrl && (
|
||||
<img
|
||||
src={linkedIssue.author.avatarUrl}
|
||||
alt={linkedIssue.author.login}
|
||||
className="h-5 w-5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
<span className="text-muted-foreground">Link GitHub Issue</span>
|
||||
</button>
|
||||
)}
|
||||
)}
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
#{linkedIssue.number}
|
||||
{linkedIssue.author && (
|
||||
<span className="ml-1">by {linkedIssue.author.login}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground truncate">
|
||||
{linkedIssue.title}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<a
|
||||
href={linkedIssue.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
||||
aria-label="Open issue in browser"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLinkedIssue(null);
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
aria-label="Remove linked issue"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{linkedPr && !isVSCode && (
|
||||
<div className="pb-2 w-full px-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrPickerOpen(true)}
|
||||
className="flex w-full items-center gap-1.5 text-sm hover:opacity-80 transition-opacity text-left h-5 px-1"
|
||||
>
|
||||
{linkedPr.author?.avatarUrl && (
|
||||
<img
|
||||
src={linkedPr.author.avatarUrl}
|
||||
alt={linkedPr.author.login}
|
||||
className="h-5 w-5 rounded-full flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-muted-foreground flex-shrink-0">
|
||||
PR #{linkedPr.number}
|
||||
{linkedPr.author && (
|
||||
<span className="ml-1">by {linkedPr.author.login}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-foreground truncate">
|
||||
{linkedPr.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground flex-shrink-0 typography-meta">
|
||||
{linkedPr.head} → {linkedPr.base}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<a
|
||||
href={linkedPr.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors"
|
||||
aria-label="Open pull request in browser"
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setLinkedPr(null);
|
||||
}}
|
||||
className="flex items-center justify-center h-6 w-6 hover:bg-[var(--interactive-hover)] rounded-full transition-colors cursor-pointer"
|
||||
aria-label="Remove linked pull request"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
@@ -2332,49 +2625,85 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
: undefined}
|
||||
/>
|
||||
)}
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
data-chat-input="true"
|
||||
value={message}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||
onKeyUp={updateAutocompleteOverlayPosition}
|
||||
onClick={updateAutocompleteOverlayPosition}
|
||||
onScroll={updateAutocompleteOverlayPosition}
|
||||
onSelect={updateAutocompleteOverlayPosition}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? "Enter shell command..."
|
||||
: "@ for files/agents; / for commands; ! for shell"
|
||||
: "Select or create a session to start chatting"}
|
||||
disabled={!currentSessionId && !newSessionDraftOpen}
|
||||
autoCorrect={isMobile ? "on" : "off"}
|
||||
autoCapitalize={isMobile ? "sentences" : "off"}
|
||||
spellCheck={isMobile}
|
||||
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
||||
className={cn(
|
||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' && 'font-mono',
|
||||
<div className="relative overflow-hidden">
|
||||
{highlightedComposerContent && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-0 whitespace-pre-wrap break-words px-3 rounded-b-none',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' ? 'font-mono' : 'typography-markdown md:typography-ui-label',
|
||||
)}
|
||||
style={{ transform: `translateY(-${textareaScrollTop}px)` }}
|
||||
>
|
||||
{highlightedComposerContent.map((part, index) => (
|
||||
<span
|
||||
key={`${index}-${part.text.length}`}
|
||||
className={
|
||||
part.mentionKind === 'file'
|
||||
? 'text-[var(--status-info)]'
|
||||
: part.mentionKind === 'agent'
|
||||
? 'text-[var(--status-success)]'
|
||||
: 'text-foreground'
|
||||
}
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
style={{
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
data-chat-input="true"
|
||||
value={message}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onPointerDownCapture={handleTextareaPointerDownCapture}
|
||||
onKeyUp={updateAutocompleteOverlayPosition}
|
||||
onClick={updateAutocompleteOverlayPosition}
|
||||
onScroll={(event) => {
|
||||
updateAutocompleteOverlayPosition();
|
||||
setTextareaScrollTop(event.currentTarget.scrollTop);
|
||||
}}
|
||||
onSelect={updateAutocompleteOverlayPosition}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? "Enter shell command..."
|
||||
: "@ for files/agents; / for commands; ! for shell"
|
||||
: "Select or create a session to start chatting"}
|
||||
disabled={!currentSessionId && !newSessionDraftOpen}
|
||||
autoCorrect={isMobile ? "on" : "off"}
|
||||
autoCapitalize={isMobile ? "sentences" : "off"}
|
||||
spellCheck={isMobile}
|
||||
outerClassName={cn('focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
||||
className={cn(
|
||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' && 'font-mono',
|
||||
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
|
||||
)}
|
||||
style={{
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: cornerRadius,
|
||||
borderTopRightRadius: cornerRadius,
|
||||
}}
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-transparent',
|
||||
@@ -2477,7 +2806,18 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
||||
open={issuePickerOpen}
|
||||
onOpenChange={setIssuePickerOpen}
|
||||
mode="select"
|
||||
onSelect={(issue) => setLinkedIssue(issue)}
|
||||
onSelect={(issue) => {
|
||||
setLinkedIssue(issue);
|
||||
setLinkedPr(null);
|
||||
}}
|
||||
/>
|
||||
<GitHubPrPickerDialog
|
||||
open={prPickerOpen}
|
||||
onOpenChange={setPrPickerOpen}
|
||||
onSelect={(pr) => {
|
||||
setLinkedPr(pr);
|
||||
setLinkedIssue(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,12 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
const DETAILED_DEFAULT_TOOLS = new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']);
|
||||
const TOOL_DEFAULT_EXPANSION_BY_MODE = {
|
||||
detailed: new Set(['task', 'edit', 'multiedit', 'write', 'apply_patch', 'bash', 'todowrite']),
|
||||
changes: new Set(['edit', 'multiedit', 'write', 'apply_patch']),
|
||||
} as const;
|
||||
|
||||
type DefaultExpandedToolMode = keyof typeof TOOL_DEFAULT_EXPANSION_BY_MODE;
|
||||
const EXPANDED_TOOLS_CACHE_MAX = 4000;
|
||||
const expandedToolsStateCache = new Map<string, Set<string>>();
|
||||
|
||||
@@ -48,8 +53,8 @@ const writeExpandedToolsCache = (messageId: string, value: Set<string>): void =>
|
||||
expandedToolsStateCache.set(messageId, new Set(value));
|
||||
};
|
||||
|
||||
const isDetailedDefaultTool = (toolName: unknown): boolean =>
|
||||
typeof toolName === 'string' && DETAILED_DEFAULT_TOOLS.has(toolName.toLowerCase());
|
||||
const isDefaultExpandedTool = (toolName: unknown, mode: DefaultExpandedToolMode): boolean =>
|
||||
typeof toolName === 'string' && TOOL_DEFAULT_EXPANSION_BY_MODE[mode].has(toolName.toLowerCase());
|
||||
|
||||
function useStickyDisplayValue<T>(value: T | null | undefined): T | null | undefined {
|
||||
const [stickyValue, setStickyValue] = React.useState<T | null | undefined>(value);
|
||||
@@ -443,19 +448,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
// 'collapsed': Activity and tools start collapsed
|
||||
// 'activity': Activity expanded, tools collapsed
|
||||
// 'detailed': Activity expanded, only key tools expanded
|
||||
// 'changes': Activity expanded, only edit/diff tools expanded
|
||||
|
||||
if (toolCallExpansion === 'collapsed' || toolCallExpansion === 'activity') {
|
||||
// Tools default collapsed: expandedTools contains IDs of tools that ARE expanded
|
||||
return expandedTools;
|
||||
}
|
||||
|
||||
// 'detailed': expand only allowlisted tools by default.
|
||||
const defaultExpansionMode =
|
||||
toolCallExpansion === 'detailed' || toolCallExpansion === 'changes'
|
||||
? toolCallExpansion
|
||||
: null;
|
||||
|
||||
if (!defaultExpansionMode) {
|
||||
return expandedTools;
|
||||
}
|
||||
|
||||
// 'detailed'/'changes': expand only allowlisted tools by default.
|
||||
// expandedTools acts as a "toggled" set (XOR with defaults).
|
||||
const defaultExpandedToolIds = new Set<string>();
|
||||
|
||||
for (const part of toolParts) {
|
||||
const toolName = (part as { tool?: unknown }).tool;
|
||||
if (part.id && isDetailedDefaultTool(toolName)) {
|
||||
if (part.id && isDefaultExpandedTool(toolName, defaultExpansionMode)) {
|
||||
defaultExpandedToolIds.add(part.id);
|
||||
}
|
||||
}
|
||||
@@ -467,7 +482,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}
|
||||
|
||||
const toolPart = activity.part as unknown as { id?: string; tool?: unknown };
|
||||
if (isDetailedDefaultTool(toolPart.tool)) {
|
||||
if (isDefaultExpandedTool(toolPart.tool, defaultExpansionMode)) {
|
||||
if (toolPart.id) {
|
||||
defaultExpandedToolIds.add(toolPart.id);
|
||||
}
|
||||
@@ -1029,7 +1044,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{showStickyInlineHoverRow ? <div aria-hidden="true" className="absolute left-0 right-0 top-full h-11" /> : null}
|
||||
{showStickyInlineHoverRow ? <div aria-hidden="true" className="pointer-events-none absolute left-0 right-0 top-full h-11" /> : null}
|
||||
</div>
|
||||
</FadeInOnReveal>
|
||||
)
|
||||
|
||||
@@ -265,10 +265,12 @@ FileChip.displayName = 'FileChip';
|
||||
export const AttachedFilesList = memo(() => {
|
||||
const { attachedFiles, removeAttachedFile } = useSessionStore();
|
||||
|
||||
if (attachedFiles.length === 0) return null;
|
||||
const localFiles = attachedFiles.filter((file) => file.source !== 'server');
|
||||
|
||||
const images = attachedFiles.filter(f => f.mimeType.startsWith('image/'));
|
||||
const otherFiles = attachedFiles.filter(f => !f.mimeType.startsWith('image/'));
|
||||
if (localFiles.length === 0) return null;
|
||||
|
||||
const images = localFiles.filter((f) => f.mimeType.startsWith('image/'));
|
||||
const otherFiles = localFiles.filter((f) => !f.mimeType.startsWith('image/'));
|
||||
|
||||
return (
|
||||
<div className="pb-4 w-full px-1 space-y-3">
|
||||
@@ -338,8 +340,135 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const imageFiles = fileItems.filter(f => f.mime?.startsWith('image/') && f.url);
|
||||
const otherFiles = fileItems.filter(f => !f.mime?.startsWith('image/'));
|
||||
|
||||
const imageGallery = React.useMemo(
|
||||
() =>
|
||||
imageFiles.flatMap((file) => {
|
||||
if (!file.url) return [];
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
return [{
|
||||
url: file.url,
|
||||
mimeType: file.mime,
|
||||
filename,
|
||||
size: file.size,
|
||||
}];
|
||||
}),
|
||||
[imageFiles]
|
||||
);
|
||||
|
||||
const handleImageClick = React.useCallback((index: number) => {
|
||||
if (!onShowPopup) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = imageGallery[index];
|
||||
if (!file?.url) return;
|
||||
|
||||
const filename = file.filename || 'Image';
|
||||
|
||||
onShowPopup({
|
||||
open: true,
|
||||
title: filename,
|
||||
content: '',
|
||||
metadata: {
|
||||
tool: 'image-preview',
|
||||
filename,
|
||||
mime: file.mimeType,
|
||||
size: file.size,
|
||||
},
|
||||
image: {
|
||||
url: file.url,
|
||||
mimeType: file.mimeType,
|
||||
filename,
|
||||
size: file.size,
|
||||
gallery: imageGallery,
|
||||
index,
|
||||
},
|
||||
});
|
||||
}, [imageGallery, onShowPopup]);
|
||||
|
||||
if (fileItems.length === 0) return null;
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<div className="space-y-1.5 mt-1.5">
|
||||
{otherFiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{otherFiles.map((file, index) => {
|
||||
const fileName = extractFilename(file.filename || file.url);
|
||||
const sizeText = formatFileSize(file.size);
|
||||
return (
|
||||
<Tooltip key={`file-${file.url || file.filename || index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex items-center bg-muted/30 border border-border/30 typography-meta gap-1 px-2 py-0.5 rounded-lg">
|
||||
{file.mime?.includes('pdf') ? (
|
||||
<RiFilePdfLine className="text-muted-foreground h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RiFileLine className="text-muted-foreground h-3.5 w-3.5" />
|
||||
)}
|
||||
<div className="overflow-hidden max-w-[140px]">
|
||||
<span className="truncate block" title={fileName}>{fileName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{fileName}{sizeText ? ` (${sizeText})` : ''}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageFiles.length > 0 && (
|
||||
<div className="overflow-x-auto -mx-1 px-1 py-0.5 scrollbar-thin">
|
||||
<div className="flex snap-x snap-mandatory gap-2">
|
||||
{imageFiles.map((file, index) => {
|
||||
const filename = extractFilename(file.filename) || 'Image';
|
||||
|
||||
return (
|
||||
<Tooltip key={`img-${file.url || file.filename || index}`} delayDuration={1000}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImageClick(index)}
|
||||
className="relative flex-none border border-border/40 bg-muted/10 overflow-hidden snap-start h-12 w-12 sm:h-14 sm:w-14 md:h-16 md:w-16 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary"
|
||||
aria-label={filename}
|
||||
>
|
||||
{file.url ? (
|
||||
<img
|
||||
src={file.url}
|
||||
alt={filename}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.visibility = 'hidden';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center bg-muted/30 text-muted-foreground">
|
||||
<RiFileImageLine className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
<span className="sr-only">{filename}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} className="typography-meta px-2 py-1">
|
||||
{filename}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"grid gap-2",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useChatSearchDirectory } from '@/hooks/useChatSearchDirectory';
|
||||
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
@@ -45,8 +46,24 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
onTabSelect,
|
||||
style,
|
||||
}, ref) => {
|
||||
const { currentDirectory } = useDirectoryStore();
|
||||
const { addServerFile } = useSessionStore();
|
||||
const currentDirectory = useChatSearchDirectory() ?? '';
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const activeProjectPath = useProjectsStore(
|
||||
React.useCallback(
|
||||
(state) => state.projects.find((project) => project.id === activeProjectId)?.path ?? null,
|
||||
[activeProjectId],
|
||||
),
|
||||
);
|
||||
const projectRoot = React.useMemo(() => {
|
||||
const candidate = activeProjectPath || currentDirectory;
|
||||
return candidate ? candidate.replace(/\\/g, '/').replace(/\/+$/, '') : null;
|
||||
}, [activeProjectPath, currentDirectory]);
|
||||
const projectTabs = useFilesViewTabsStore(
|
||||
React.useCallback(
|
||||
(state) => (projectRoot ? state.byRoot[projectRoot] : undefined),
|
||||
[projectRoot],
|
||||
),
|
||||
);
|
||||
const { getVisibleAgents } = useConfigStore();
|
||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||
const debouncedQuery = useDebouncedValue(searchQuery, 180);
|
||||
@@ -67,55 +84,43 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
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 recentFiles = React.useMemo(() => {
|
||||
if (!projectRoot || !projectTabs) {
|
||||
return [] as FileInfo[];
|
||||
}
|
||||
|
||||
const c = candidate.toLowerCase();
|
||||
let score = 0;
|
||||
let lastIndex = -1;
|
||||
let consecutive = 0;
|
||||
const ordered = [
|
||||
projectTabs.selectedPath,
|
||||
...projectTabs.openPaths.slice().reverse(),
|
||||
].filter((value): value is string => typeof value === 'string' && value.length > 0);
|
||||
|
||||
for (let i = 0; i < q.length; i += 1) {
|
||||
const ch = q[i];
|
||||
if (!ch || ch === ' ') {
|
||||
continue;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const queryLower = normalizedSearchQuery.toLowerCase();
|
||||
const mapped = ordered
|
||||
.filter((filePath) => {
|
||||
if (seen.has(filePath)) return false;
|
||||
seen.add(filePath);
|
||||
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
|
||||
if (!queryLower) return true;
|
||||
return relative.toLowerCase().includes(queryLower);
|
||||
})
|
||||
.slice(0, 6)
|
||||
.map((filePath) => {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const name = normalizedPath.split('/').filter(Boolean).pop() || normalizedPath;
|
||||
const relativePath = normalizedPath.startsWith(`${projectRoot}/`)
|
||||
? normalizedPath.slice(projectRoot.length + 1)
|
||||
: normalizedPath;
|
||||
return {
|
||||
name,
|
||||
path: normalizedPath,
|
||||
relativePath,
|
||||
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
|
||||
} satisfies FileInfo;
|
||||
});
|
||||
|
||||
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;
|
||||
}, []);
|
||||
return mapped;
|
||||
}, [normalizedSearchQuery, projectRoot, projectTabs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
@@ -138,6 +143,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
React.useEffect(() => {
|
||||
if (!currentDirectory) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,35 +153,27 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
.replace(/^\/+/, '')
|
||||
.toLowerCase();
|
||||
|
||||
if (!normalizedQueryLower) {
|
||||
setFiles([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQueryLower, 80, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
type: 'file',
|
||||
})
|
||||
.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));
|
||||
const recentSet = new Set(recentFiles.map((file) => file.path));
|
||||
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
@@ -191,7 +189,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentDirectory, debouncedQuery, fuzzyScore, searchFiles, showHidden, showGitignored]);
|
||||
}, [currentDirectory, debouncedQuery, recentFiles, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const visibleAgents = getVisibleAgents();
|
||||
@@ -216,7 +214,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
setSelectedIndex(0);
|
||||
setOverflowMap({});
|
||||
setMarqueeDurations({});
|
||||
}, [files, visibleAgents.length]);
|
||||
}, [files, recentFiles.length, visibleAgents.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
itemRefs.current[selectedIndex]?.scrollIntoView({
|
||||
@@ -292,11 +290,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
};
|
||||
}, [selectedIndex]);
|
||||
|
||||
const handleFileSelect = React.useCallback(async (file: FileInfo) => {
|
||||
|
||||
await addServerFile(file.path, file.name);
|
||||
const handleFileSelect = React.useCallback((file: FileInfo) => {
|
||||
onFileSelect(file);
|
||||
}, [addServerFile, onFileSelect]);
|
||||
}, [onFileSelect]);
|
||||
|
||||
const handleAgentPick = React.useCallback((agentName: string) => {
|
||||
onAgentSelect?.(agentName);
|
||||
@@ -309,7 +305,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
return;
|
||||
}
|
||||
|
||||
const total = visibleAgents.length + files.length;
|
||||
const total = visibleAgents.length + recentFiles.length + files.length;
|
||||
if (total === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -333,13 +329,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
}
|
||||
return;
|
||||
}
|
||||
const selectedFile = files[safeIndex - visibleAgents.length];
|
||||
const fileIndex = safeIndex - visibleAgents.length;
|
||||
const selectedFile = fileIndex < recentFiles.length
|
||||
? recentFiles[fileIndex]
|
||||
: files[fileIndex - recentFiles.length];
|
||||
if (selectedFile) {
|
||||
handleFileSelect(selectedFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}), [files, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
||||
}), [files, recentFiles, visibleAgents, selectedIndex, onClose, handleFileSelect, handleAgentPick]);
|
||||
|
||||
const getFileIcon = (file: FileInfo) => {
|
||||
const ext = file.extension?.toLowerCase();
|
||||
@@ -440,11 +439,68 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{visibleAgents.length > 0 && files.length > 0 && (
|
||||
{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>
|
||||
)}
|
||||
{visibleAgents.length > 0 && (recentFiles.length > 0 || files.length > 0) && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{recentFiles.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
const isOverflowing = overflowMap[rowIndex] ?? false;
|
||||
const marqueeDuration = marqueeDurations[rowIndex] ?? 2.6;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`recent-${file.path}`}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{recentFiles.length > 0 && files.length > 0 && (
|
||||
<div className="my-1 border-t border-border/60" />
|
||||
)}
|
||||
{files.map((file, index) => {
|
||||
const rowIndex = visibleAgents.length + index;
|
||||
const rowIndex = visibleAgents.length + recentFiles.length + index;
|
||||
const relativePath = file.relativePath || file.name;
|
||||
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
|
||||
const isSelected = selectedIndex === rowIndex;
|
||||
@@ -497,12 +553,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
|
||||
</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 && (
|
||||
{files.length === 0 && recentFiles.length === 0 && visibleAgents.length === 0 && (
|
||||
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
|
||||
No matches found
|
||||
</div>
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { RiCloseLine, RiFolder6Line, RiSearchLine } from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, truncatePathMiddle } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useDirectoryShowHidden } from '@/lib/directoryShowHidden';
|
||||
import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
extension?: string;
|
||||
relativePath?: string;
|
||||
}
|
||||
|
||||
interface ServerFilePickerProps {
|
||||
onFilesSelected: (files: FileInfo[]) => void;
|
||||
multiSelect?: boolean;
|
||||
children: React.ReactNode;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
presentation?: 'dropdown' | 'modal';
|
||||
}
|
||||
|
||||
export const ServerFilePicker: React.FC<ServerFilePickerProps> = ({
|
||||
onFilesSelected,
|
||||
multiSelect = false,
|
||||
children,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
presentation = 'dropdown',
|
||||
}) => {
|
||||
const { isMobile } = useDeviceInfo();
|
||||
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
|
||||
const isCompact = isMobile;
|
||||
const { currentDirectory } = useDirectoryStore();
|
||||
const searchFiles = useFileSearchStore((state) => state.searchFiles);
|
||||
const showHidden = useDirectoryShowHidden();
|
||||
const showGitignored = useFilesViewShowGitignored();
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
|
||||
const [cacheNonce, setCacheNonce] = React.useState(0);
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 200);
|
||||
const [selectedFiles, setSelectedFiles] = React.useState<Set<string>>(new Set());
|
||||
const [expandedDirs, setExpandedDirs] = React.useState<Set<string>>(new Set());
|
||||
const [childrenByDir, setChildrenByDir] = React.useState<Record<string, FileInfo[]>>({});
|
||||
const loadedDirsRef = React.useRef<Set<string>>(new Set());
|
||||
const inFlightDirsRef = React.useRef<Set<string>>(new Set());
|
||||
const [inFlightDirs, setInFlightDirs] = React.useState<Set<string>>(new Set());
|
||||
const [searchResults, setSearchResults] = React.useState<FileInfo[]>([]);
|
||||
const [searching, setSearching] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [attaching, setAttaching] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const open = controlledOpen ?? uncontrolledOpen;
|
||||
const setOpen = onOpenChange ?? setUncontrolledOpen;
|
||||
|
||||
const updateInFlightDirs = React.useCallback((next: Set<string>) => {
|
||||
inFlightDirsRef.current = next;
|
||||
setInFlightDirs(next);
|
||||
}, []);
|
||||
|
||||
const sortDirectoryItems = React.useCallback((items: FileInfo[]) => (
|
||||
items.slice().sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
})
|
||||
), []);
|
||||
|
||||
const mapFilesystemEntries = React.useCallback((dirPath: string, entries: Array<{ name: string; path: string; isDirectory: boolean }>): FileInfo[] => (
|
||||
sortDirectoryItems(entries
|
||||
.filter((item) => showHidden || !item.name.startsWith('.'))
|
||||
.map((item) => {
|
||||
const name = item.name;
|
||||
const extension = !item.isDirectory && name.includes('.')
|
||||
? name.split('.').pop()?.toLowerCase()
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
path: item.path || `${dirPath}/${name}`,
|
||||
type: item.isDirectory ? 'directory' : 'file',
|
||||
size: 0,
|
||||
extension,
|
||||
};
|
||||
}))
|
||||
), [sortDirectoryItems, showHidden]);
|
||||
|
||||
const loadDirectory = React.useCallback(async (dirPath: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
await opencodeClient.listLocalDirectory(dirPath, { respectGitignore: !showGitignored })
|
||||
.then((entries) => {
|
||||
const items = mapFilesystemEntries(dirPath, entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
})));
|
||||
|
||||
loadedDirsRef.current = new Set([dirPath]);
|
||||
updateInFlightDirs(new Set());
|
||||
setChildrenByDir({ [dirPath]: items });
|
||||
setExpandedDirs(new Set());
|
||||
})
|
||||
.catch(() => {
|
||||
setError('Failed to load directory contents');
|
||||
loadedDirsRef.current = new Set([dirPath]);
|
||||
updateInFlightDirs(new Set());
|
||||
setChildrenByDir({ [dirPath]: [] });
|
||||
setExpandedDirs(new Set());
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [mapFilesystemEntries, showGitignored, updateInFlightDirs]);
|
||||
|
||||
const loadDirectoryChildren = React.useCallback(async (dirPath: string) => {
|
||||
const normalizedDir = dirPath.trim();
|
||||
if (!normalizedDir) {
|
||||
return;
|
||||
}
|
||||
const cacheKey = `${normalizedDir}::${cacheNonce}`;
|
||||
if (loadedDirsRef.current.has(cacheKey)) {
|
||||
return;
|
||||
}
|
||||
if (inFlightDirsRef.current.has(cacheKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextInFlight = new Set(inFlightDirsRef.current);
|
||||
nextInFlight.add(cacheKey);
|
||||
updateInFlightDirs(nextInFlight);
|
||||
|
||||
await opencodeClient.listLocalDirectory(normalizedDir, { respectGitignore: !showGitignored })
|
||||
.then((entries) => {
|
||||
const items = mapFilesystemEntries(normalizedDir, entries.map((entry) => ({
|
||||
name: entry.name,
|
||||
path: entry.path,
|
||||
isDirectory: entry.isDirectory,
|
||||
})));
|
||||
|
||||
loadedDirsRef.current = new Set(loadedDirsRef.current);
|
||||
loadedDirsRef.current.add(cacheKey);
|
||||
setChildrenByDir((prev) => ({
|
||||
...prev,
|
||||
[normalizedDir]: items,
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
setChildrenByDir((prev) => {
|
||||
if (prev[normalizedDir]) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[normalizedDir]: [],
|
||||
};
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
const updatedInFlightDirs = new Set(inFlightDirsRef.current);
|
||||
updatedInFlightDirs.delete(cacheKey);
|
||||
updateInFlightDirs(updatedInFlightDirs);
|
||||
});
|
||||
}, [mapFilesystemEntries, showGitignored, cacheNonce, updateInFlightDirs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if ((open || mobileOpen) && currentDirectory) {
|
||||
void loadDirectory(currentDirectory);
|
||||
}
|
||||
}, [open, mobileOpen, currentDirectory, loadDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCacheNonce((prev) => prev + 1);
|
||||
}, [showHidden, showGitignored]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!(open || mobileOpen) || !currentDirectory) {
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedQuery = debouncedSearchQuery
|
||||
.trim()
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\/+/, '');
|
||||
if (!trimmedQuery) {
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = trimmedQuery.toLowerCase();
|
||||
|
||||
let cancelled = false;
|
||||
setSearching(true);
|
||||
|
||||
searchFiles(currentDirectory, normalizedQuery, 150, {
|
||||
includeHidden: showHidden,
|
||||
respectGitignore: !showGitignored,
|
||||
})
|
||||
.then((hits) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
const mappedHits: FileInfo[] = hits.map((hit) => ({
|
||||
name: hit.name,
|
||||
path: hit.path,
|
||||
type: 'file',
|
||||
extension: hit.extension,
|
||||
relativePath: hit.relativePath,
|
||||
size: 0,
|
||||
}));
|
||||
setSearchResults(mappedHits);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSearchResults([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setSearching(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, mobileOpen, currentDirectory, debouncedSearchQuery, searchFiles, showHidden, showGitignored]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open && !mobileOpen) {
|
||||
setSelectedFiles(new Set());
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setSearching(false);
|
||||
loadedDirsRef.current = new Set();
|
||||
updateInFlightDirs(new Set());
|
||||
setChildrenByDir({});
|
||||
setExpandedDirs(new Set());
|
||||
}
|
||||
}, [open, mobileOpen, updateInFlightDirs]);
|
||||
|
||||
const getFileIcon = (file: FileInfo) => {
|
||||
if (file.type === 'directory') {
|
||||
return expandedDirs.has(file.path) ? (
|
||||
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
|
||||
) : (
|
||||
<RiFolder6Line className="h-3.5 w-3.5 text-primary/60" />
|
||||
);
|
||||
}
|
||||
|
||||
return <FileTypeIcon filePath={file.path} extension={file.extension} className="h-3.5 w-3.5" />;
|
||||
};
|
||||
|
||||
const toggleDirectory = async (dirPath: string) => {
|
||||
const isExpanded = expandedDirs.has(dirPath);
|
||||
|
||||
if (isExpanded) {
|
||||
setExpandedDirs(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(dirPath);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(dirPath);
|
||||
return next;
|
||||
});
|
||||
|
||||
await loadDirectoryChildren(dirPath);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFileSelection = (filePath: string) => {
|
||||
if (multiSelect) {
|
||||
setSelectedFiles(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(filePath)) {
|
||||
next.delete(filePath);
|
||||
} else {
|
||||
next.add(filePath);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedFiles(new Set([filePath]));
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const treeFileMap = new Map<string, FileInfo>();
|
||||
Object.values(childrenByDir).forEach((items) => {
|
||||
items.forEach((file) => {
|
||||
if (file.type === 'file') {
|
||||
treeFileMap.set(file.path, file);
|
||||
}
|
||||
});
|
||||
});
|
||||
const searchFileMap = new Map(searchResults.map((file) => [file.path, file]));
|
||||
|
||||
const selected = Array.from(selectedFiles)
|
||||
.map((filePath) => treeFileMap.get(filePath) ?? searchFileMap.get(filePath))
|
||||
.filter((file): file is FileInfo => Boolean(file));
|
||||
|
||||
setAttaching(true);
|
||||
await Promise.resolve(onFilesSelected(selected))
|
||||
.then(() => {
|
||||
setSelectedFiles(new Set());
|
||||
setOpen(false);
|
||||
setMobileOpen(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setAttaching(false);
|
||||
});
|
||||
};
|
||||
|
||||
const rootItems = React.useMemo(() => {
|
||||
if (!currentDirectory) {
|
||||
return [];
|
||||
}
|
||||
return childrenByDir[currentDirectory] ?? [];
|
||||
}, [childrenByDir, currentDirectory]);
|
||||
|
||||
const isSearchActive = searchQuery.trim().length > 0;
|
||||
|
||||
const getChildItems = (parentPath: string) => {
|
||||
return childrenByDir[parentPath] ?? [];
|
||||
};
|
||||
|
||||
const getRelativePath = (fullPath: string) => {
|
||||
if (currentDirectory && fullPath.startsWith(currentDirectory)) {
|
||||
const relativePath = fullPath.substring(currentDirectory.length);
|
||||
return relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
||||
}
|
||||
return fullPath.split('/').pop() || fullPath;
|
||||
};
|
||||
|
||||
const renderFileItem = (file: FileInfo, level: number) => {
|
||||
const rawLabel = isSearchActive
|
||||
? file.relativePath || getRelativePath(file.path)
|
||||
: file.name;
|
||||
const shouldCompact = isSearchActive && rawLabel.includes('/') && rawLabel.length > 45;
|
||||
const displayLabel = shouldCompact
|
||||
? truncatePathMiddle(rawLabel, { maxLength: isCompact ? 42 : 48 })
|
||||
: rawLabel;
|
||||
|
||||
const row = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded hover:bg-interactive-hover cursor-pointer typography-ui-label text-foreground text-left",
|
||||
file.type === 'file' && selectedFiles.has(file.path) && "bg-primary/10"
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (file.type === 'file') {
|
||||
toggleFileSelection(file.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-start gap-1">
|
||||
<span className="text-muted-foreground">{getFileIcon(file)}</span>
|
||||
<span className="flex-1 truncate text-foreground text-left max-w-[360px]" aria-label={rawLabel}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
</div>
|
||||
{file.type === 'file' && selectedFiles.has(file.path) && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!shouldCompact) {
|
||||
return React.cloneElement(row, { key: file.path });
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip key={file.path} delayDuration={120}>
|
||||
<TooltipTrigger asChild>{row}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<span className="typography-meta text-foreground/80 whitespace-pre-wrap break-all">
|
||||
{rawLabel}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFileTree = (file: FileInfo, level: number): React.ReactNode => {
|
||||
const isDirectory = file.type === 'directory';
|
||||
const children = isDirectory ? getChildItems(file.path) : [];
|
||||
const isExpanded = expandedDirs.has(file.path);
|
||||
const isLoadingChildren = isDirectory && isExpanded && inFlightDirs.has(file.path) && children.length === 0;
|
||||
|
||||
return (
|
||||
<div key={file.path}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-start gap-1 px-2 py-1.5 rounded cursor-pointer typography-ui-label text-foreground text-left',
|
||||
!isDirectory && selectedFiles.has(file.path) && 'bg-primary/10'
|
||||
)}
|
||||
style={{ paddingLeft: `${level * 12}px` }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDirectory) {
|
||||
void toggleDirectory(file.path);
|
||||
} else {
|
||||
toggleFileSelection(file.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-muted-foreground">{getFileIcon(file)}</span>
|
||||
<span className="flex-1 truncate text-foreground text-left">
|
||||
{file.name}
|
||||
</span>
|
||||
{!isDirectory && selectedFiles.has(file.path) && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isDirectory && isExpanded && children.length > 0 && (
|
||||
<div>
|
||||
{children.map((child) => renderFileTree(child, level + 1))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDirectory && isExpanded && isLoadingChildren && (
|
||||
<div
|
||||
className="px-2 py-1.5 typography-ui-label text-muted-foreground"
|
||||
style={{ paddingLeft: `${(level + 1) * 12}px` }}
|
||||
>
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const summaryLabel = selectedFiles.size > 0
|
||||
? `${selectedFiles.size} file${selectedFiles.size !== 1 ? 's' : ''} selected`
|
||||
: 'No files selected';
|
||||
|
||||
const summarySection = (
|
||||
<div className="flex items-center justify-between px-3 py-2 shrink-0">
|
||||
<div className="typography-meta text-muted-foreground">{summaryLabel}</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedFiles.size === 0 || attaching}
|
||||
className="h-6 typography-meta"
|
||||
>
|
||||
{attaching ? 'Attaching...' : 'Attach Files'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const scrollAreaClass = isCompact ? 'flex-1 min-h-[240px]' : 'h-[300px]';
|
||||
|
||||
const pickerBody = (
|
||||
<>
|
||||
<div className="px-3 py-2 border-b shrink-0">
|
||||
<div className="font-medium typography-ui-label text-foreground">Select Project Files</div>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b shrink-0">
|
||||
<div className="relative">
|
||||
<RiSearchLine className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="pl-7 h-6 typography-ui-label"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 hover:bg-interactive-hover rounded"
|
||||
>
|
||||
<RiCloseLine className="h-3 w-3"/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className={scrollAreaClass}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="typography-ui-label text-muted-foreground">Loading files...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="typography-ui-label text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="py-1 px-2">
|
||||
{isSearchActive ? (
|
||||
searching ? (
|
||||
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||
Searching files…
|
||||
</div>
|
||||
) : (
|
||||
searchResults.map((file) => renderFileItem(file, 0))
|
||||
)
|
||||
) : (
|
||||
rootItems.map((file) => renderFileTree(file, 0))
|
||||
)}
|
||||
|
||||
{!isSearchActive && rootItems.length === 0 && (
|
||||
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||
No files in this directory
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSearchActive && !searching && searchResults.length === 0 && (
|
||||
<div className="px-3 py-4 typography-ui-label text-muted-foreground text-center">
|
||||
No files found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</>
|
||||
);
|
||||
|
||||
const mobileTrigger = (
|
||||
<span
|
||||
className="inline-flex cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setMobileOpen(true);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (presentation === 'modal') {
|
||||
return (
|
||||
<>
|
||||
{children ? (
|
||||
<span
|
||||
className="inline-flex cursor-pointer"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
<MobileOverlayPanel
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="Select Project Files"
|
||||
footer={summarySection}
|
||||
>
|
||||
<div className="flex flex-col gap-0">{pickerBody}</div>
|
||||
</MobileOverlayPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<>
|
||||
{mobileTrigger}
|
||||
<MobileOverlayPanel
|
||||
open={mobileOpen}
|
||||
onClose={() => setMobileOpen(false)}
|
||||
title="Select Project Files"
|
||||
footer={summarySection}
|
||||
>
|
||||
<div className="flex flex-col gap-0">{pickerBody}</div>
|
||||
</MobileOverlayPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{children}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className={cn(
|
||||
'p-0 overflow-hidden flex flex-col',
|
||||
'w-[min(520px,calc(100vw-24px))]'
|
||||
)}
|
||||
align="start"
|
||||
sideOffset={5}
|
||||
collisionPadding={12}
|
||||
>
|
||||
{pickerBody}
|
||||
<DropdownMenuSeparator />
|
||||
{summarySection}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,15 @@
|
||||
import React from "react";
|
||||
import { RiArrowUpSLine, RiArrowDownSLine, RiCloseCircleLine } from "@remixicon/react";
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowUpDoubleLine,
|
||||
RiArrowUpSLine,
|
||||
RiCheckboxCircleLine,
|
||||
RiCloseCircleLine,
|
||||
RiRecordCircleLine,
|
||||
RiTimeLine,
|
||||
} from "@remixicon/react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTodoStore, type TodoItem, type TodoStatus } from "@/stores/useTodoStore";
|
||||
import { useTodoStore, type TodoItem, type TodoPriority, type TodoStatus } from "@/stores/useTodoStore";
|
||||
import { useSessionStore } from "@/stores/useSessionStore";
|
||||
import { useUIStore } from "@/stores/useUIStore";
|
||||
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
|
||||
@@ -22,6 +30,18 @@ const statusConfig: Record<TodoStatus, { textClassName: string }> = {
|
||||
},
|
||||
};
|
||||
|
||||
const priorityClassName: Record<TodoPriority, string> = {
|
||||
high: "text-[var(--status-warning)]",
|
||||
medium: "text-muted-foreground",
|
||||
low: "text-muted-foreground/70",
|
||||
};
|
||||
|
||||
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
|
||||
high: <RiArrowUpDoubleLine className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
medium: <RiArrowUpSLine className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
low: <RiArrowDownSLine className="h-3.5 w-3.5" aria-hidden="true" />,
|
||||
};
|
||||
|
||||
interface TodoItemRowProps {
|
||||
todo: TodoItem;
|
||||
}
|
||||
@@ -29,8 +49,18 @@ interface TodoItemRowProps {
|
||||
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
||||
const config = statusConfig[todo.status] || statusConfig.pending;
|
||||
|
||||
const statusIcon =
|
||||
todo.status === "in_progress" ? (
|
||||
<RiRecordCircleLine className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
|
||||
) : todo.status === "completed" ? (
|
||||
<RiCheckboxCircleLine className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
|
||||
) : (
|
||||
<RiTimeLine className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-start min-w-0 py-0.5">
|
||||
<div className="flex items-start min-w-0 py-0.5 gap-2">
|
||||
<span className="mt-0.5 flex-shrink-0">{statusIcon}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-1 typography-ui-label",
|
||||
@@ -39,6 +69,15 @@ const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
|
||||
>
|
||||
{todo.content}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"typography-meta flex-shrink-0",
|
||||
priorityClassName[todo.priority] ?? priorityClassName.medium
|
||||
)}
|
||||
title={`${todo.priority} priority`}
|
||||
>
|
||||
{priorityIcon[todo.priority] ?? priorityIcon.medium}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -89,18 +128,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
}
|
||||
}, [currentSessionId, loadTodos]);
|
||||
|
||||
// Filter out cancelled todos for display, sort by status priority
|
||||
// Filter out cancelled todos for display and keep original order.
|
||||
// This prevents items from jumping around when status changes.
|
||||
const visibleTodos = React.useMemo(() => {
|
||||
const statusOrder: Record<TodoStatus, number> = {
|
||||
in_progress: 0,
|
||||
pending: 1,
|
||||
completed: 2,
|
||||
cancelled: 3,
|
||||
};
|
||||
|
||||
return [...todos]
|
||||
.filter((todo) => todo.status !== "cancelled")
|
||||
.sort((a, b) => statusOrder[a.status] - statusOrder[b.status]);
|
||||
return todos.filter((todo) => todo.status !== "cancelled");
|
||||
}, [todos]);
|
||||
|
||||
// Find the current active todo (first in_progress, or first pending)
|
||||
@@ -119,6 +150,12 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
return { completed, total };
|
||||
}, [todos]);
|
||||
|
||||
const statusSummary = React.useMemo(() => {
|
||||
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
|
||||
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
|
||||
return { active, left };
|
||||
}, [visibleTodos]);
|
||||
|
||||
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
|
||||
// Original logic from ChatInput
|
||||
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
|
||||
@@ -178,7 +215,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
|
||||
<span className="typography-ui-label">Tasks</span>
|
||||
)}
|
||||
<span className="typography-meta">
|
||||
{progress.completed}/{progress.total}
|
||||
{statusSummary.active} active · {statusSummary.left} left
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<RiArrowUpSLine className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -514,7 +514,8 @@ export const TurnGroupingProvider: React.FC<TurnGroupingProviderProps> = ({ mess
|
||||
const { isWorking: sessionIsWorking } = useCurrentSessionActivity();
|
||||
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||
const showTextJustificationActivity = useUIStore((state) => state.showTextJustificationActivity);
|
||||
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
|
||||
const defaultActivityExpanded =
|
||||
toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
|
||||
const structureKey = React.useMemo(() => getStructureKey(messages), [messages]);
|
||||
const [structuredMessages, setStructuredMessages] = React.useState<ChatMessageEntry[]>(messages);
|
||||
|
||||
|
||||
@@ -423,8 +423,9 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
||||
);
|
||||
|
||||
const toolCallExpansion = useUIStore((state) => state.toolCallExpansion);
|
||||
// Activity group is expanded for 'activity' and 'detailed', collapsed for 'collapsed'
|
||||
const defaultActivityExpanded = toolCallExpansion === 'activity' || toolCallExpansion === 'detailed';
|
||||
// Activity group is expanded for 'activity', 'detailed', and 'changes'; collapsed for 'collapsed'
|
||||
const defaultActivityExpanded =
|
||||
toolCallExpansion === 'activity' || toolCallExpansion === 'detailed' || toolCallExpansion === 'changes';
|
||||
|
||||
// Reset turn UI states when the expansion preference changes
|
||||
// This ensures the setting takes precedence over manual toggles
|
||||
|
||||
@@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
|
||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react';
|
||||
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiImageDownloadLine, RiLoader4Line } from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useMessageTTS } from '@/hooks/useMessageTTS';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { TextSelectionMenu } from './TextSelectionMenu';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { toPng } from 'html-to-image';
|
||||
import { toast } from '@/components/ui';
|
||||
import { formatTimestampForDisplay } from './timeFormat';
|
||||
@@ -332,6 +333,7 @@ const UserMessageBody: React.FC<{
|
||||
const hasCopyableText = Boolean(hasTextContent);
|
||||
const showUserContent = userActionsMode !== 'external-actions';
|
||||
const showUserActions = userActionsMode !== 'external-content';
|
||||
const useStickyScrollableUserContent = stickyUserHeaderEnabled && userActionsMode === 'inline';
|
||||
|
||||
const clearCopyHintTimeout = React.useCallback(() => {
|
||||
if (copyHintTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
@@ -490,7 +492,15 @@ const UserMessageBody: React.FC<{
|
||||
style={{ contain: 'layout', transform: 'translateZ(0)' }}
|
||||
onTouchStart={isTouchContext && canCopyMessage && hasCopyableText ? revealCopyHint : undefined}
|
||||
>
|
||||
<div className="leading-relaxed overflow-hidden text-foreground/90 text-base">
|
||||
<div
|
||||
className={cn(
|
||||
'leading-relaxed text-foreground/90 text-base overflow-x-hidden',
|
||||
useStickyScrollableUserContent
|
||||
? 'overflow-y-auto overscroll-contain scrollbar-none'
|
||||
: 'overflow-y-hidden'
|
||||
)}
|
||||
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
|
||||
>
|
||||
{userContentParts.map((part, index) => {
|
||||
if (isSubtaskPart(part)) {
|
||||
return (
|
||||
@@ -833,12 +843,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
try {
|
||||
const originalElement = messageContentRef.current;
|
||||
const computedStyle = window.getComputedStyle(originalElement);
|
||||
const rootStyle = window.getComputedStyle(document.documentElement);
|
||||
const resolvedBackgroundColor =
|
||||
rootStyle.getPropertyValue('--surface-background').trim() ||
|
||||
computedStyle.backgroundColor ||
|
||||
window.getComputedStyle(document.body).backgroundColor;
|
||||
const paddingSize = 24;
|
||||
|
||||
wrapper = document.createElement('div');
|
||||
wrapper.style.cssText = `
|
||||
padding: ${paddingSize}px;
|
||||
background-color: var(--surface-background);
|
||||
background-color: ${resolvedBackgroundColor};
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
@@ -849,21 +864,69 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
contain: none;
|
||||
`;
|
||||
|
||||
const timestampElements = clone.querySelectorAll<HTMLElement>('[aria-label^="Message time:"]');
|
||||
const footerRowsAdjusted = new Set<HTMLElement>();
|
||||
timestampElements.forEach((element) => {
|
||||
const label = element.getAttribute('aria-label');
|
||||
const timestamp = label?.replace('Message time:', '').trim();
|
||||
if (!timestamp || element.textContent?.includes(timestamp)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timestampText = document.createElement('span');
|
||||
timestampText.style.marginLeft = '4px';
|
||||
timestampText.textContent = timestamp;
|
||||
element.appendChild(timestampText);
|
||||
|
||||
const metaGroup = element.parentElement;
|
||||
const footerRow = metaGroup?.parentElement as HTMLElement | null;
|
||||
const actionsGroup = footerRow?.firstElementChild as HTMLElement | null;
|
||||
if (!footerRow || !actionsGroup || actionsGroup === metaGroup || footerRowsAdjusted.has(footerRow)) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionsGroup.style.display = 'none';
|
||||
footerRow.style.justifyContent = 'flex-start';
|
||||
footerRowsAdjusted.add(footerRow);
|
||||
});
|
||||
|
||||
wrapper.appendChild(clone);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
const dataUrl = await toPng(wrapper, {
|
||||
quality: 1,
|
||||
pixelRatio: 2,
|
||||
backgroundColor: 'var(--surface-background)',
|
||||
backgroundColor: resolvedBackgroundColor,
|
||||
});
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.download = `message-${messageId}.png`;
|
||||
link.href = dataUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
const fileName = `message-${messageId}.png`;
|
||||
|
||||
if (isVSCodeRuntime()) {
|
||||
const response = await fetch('/api/vscode/save-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ fileName, dataUrl }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save image in VS Code');
|
||||
}
|
||||
|
||||
const payload = await response.json() as { saved?: boolean; canceled?: boolean; error?: string };
|
||||
if (payload.saved !== true) {
|
||||
if (payload.canceled) {
|
||||
return;
|
||||
}
|
||||
throw new Error(payload.error || 'Failed to save image in VS Code');
|
||||
}
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.download = fileName;
|
||||
link.href = dataUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
toast.success('Image saved');
|
||||
} catch (error) {
|
||||
@@ -1289,7 +1352,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
{isSharing ? (
|
||||
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RiShare2Line className="h-4 w-4" />
|
||||
<RiImageDownloadLine className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
@@ -1399,10 +1462,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
</span>
|
||||
) : null}
|
||||
{footerTimestamp ? (
|
||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||
<RiTimeLine className="h-3.5 w-3.5" />
|
||||
{footerTimestamp}
|
||||
</span>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="text-sm text-muted-foreground/60 tabular-nums flex items-center"
|
||||
aria-label={`Message time: ${footerTimestamp}`}
|
||||
>
|
||||
<RiTimeLine className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{footerTimestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1425,10 +1495,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
||||
</span>
|
||||
) : null}
|
||||
{footerTimestamp ? (
|
||||
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
|
||||
<RiTimeLine className="h-3.5 w-3.5" />
|
||||
{footerTimestamp}
|
||||
</span>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="text-sm text-muted-foreground/60 tabular-nums flex items-center"
|
||||
aria-label={`Message time: ${footerTimestamp}`}
|
||||
>
|
||||
<RiTimeLine className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{footerTimestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,28 +16,29 @@ interface MenuPosition {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const MENU_TRANSITION_MS = 200;
|
||||
const DESKTOP_MENU_SIDE_MARGIN_PX = 8;
|
||||
const DESKTOP_MENU_FALLBACK_WIDTH_PX = 280;
|
||||
|
||||
export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerRef }) => {
|
||||
const [position, setPosition] = React.useState<MenuPosition>({ x: 0, y: 0, show: false });
|
||||
const [selectedText, setSelectedText] = React.useState('');
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [isClosing, setIsClosing] = React.useState(false);
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const menuWidthRef = React.useRef(DESKTOP_MENU_FALLBACK_WIDTH_PX);
|
||||
const pendingSelectionRef = React.useRef<{ text: string; rect: DOMRect } | null>(null);
|
||||
const hideTimeoutRef = React.useRef<number | null>(null);
|
||||
const openRafRef = React.useRef<number | null>(null);
|
||||
const isMenuVisibleRef = React.useRef(false);
|
||||
const createSession = useSessionStore((state) => state.createSession);
|
||||
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMenuVisibleRef.current = position.show;
|
||||
}, [position.show]);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (hideTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
if (openRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(openRafRef.current);
|
||||
openRafRef.current = null;
|
||||
@@ -46,40 +47,51 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
}, []);
|
||||
|
||||
const hideMenu = React.useCallback(() => {
|
||||
if (hideTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
pendingSelectionRef.current = null;
|
||||
|
||||
if (!isMenuVisibleRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (openRafRef.current !== null) {
|
||||
window.cancelAnimationFrame(openRafRef.current);
|
||||
openRafRef.current = null;
|
||||
}
|
||||
setIsOpening(false);
|
||||
|
||||
setIsClosing(true);
|
||||
hideTimeoutRef.current = window.setTimeout(() => {
|
||||
setPosition((prev) => ({ ...prev, show: false }));
|
||||
setSelectedText('');
|
||||
pendingSelectionRef.current = null;
|
||||
setIsClosing(false);
|
||||
hideTimeoutRef.current = null;
|
||||
}, MENU_TRANSITION_MS);
|
||||
setPosition((prev) => ({ ...prev, show: false }));
|
||||
setSelectedText('');
|
||||
isMenuVisibleRef.current = false;
|
||||
}, []);
|
||||
|
||||
const getDesktopClampedX = React.useCallback((anchorX: number) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return anchorX;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const menuWidth = menuWidthRef.current;
|
||||
const halfWidth = menuWidth / 2;
|
||||
const minX = DESKTOP_MENU_SIDE_MARGIN_PX + halfWidth;
|
||||
const maxX = viewportWidth - DESKTOP_MENU_SIDE_MARGIN_PX - halfWidth;
|
||||
|
||||
if (minX > maxX) {
|
||||
return viewportWidth / 2;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(anchorX, minX), maxX);
|
||||
}, []);
|
||||
|
||||
const showMenu = React.useCallback(() => {
|
||||
if (!pendingSelectionRef.current) return;
|
||||
|
||||
if (hideTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
setIsClosing(false);
|
||||
|
||||
const { text, rect } = pendingSelectionRef.current;
|
||||
const shouldAnimateIn = !position.show;
|
||||
|
||||
// Position menu above the selection
|
||||
const menuX = rect.left + rect.width / 2;
|
||||
const menuX = isMobile
|
||||
? rect.left + rect.width / 2
|
||||
: getDesktopClampedX(rect.left + rect.width / 2);
|
||||
const menuY = rect.top - 10;
|
||||
|
||||
setSelectedText(text);
|
||||
@@ -88,6 +100,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
y: menuY,
|
||||
show: true,
|
||||
});
|
||||
isMenuVisibleRef.current = true;
|
||||
|
||||
if (shouldAnimateIn) {
|
||||
setIsOpening(true);
|
||||
@@ -99,7 +112,42 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
openRafRef.current = null;
|
||||
});
|
||||
}
|
||||
}, [position.show]);
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!position.show || isMobile || !menuRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measuredWidth = menuRef.current.offsetWidth;
|
||||
if (!Number.isFinite(measuredWidth) || measuredWidth <= 0 || measuredWidth === menuWidthRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
menuWidthRef.current = measuredWidth;
|
||||
setPosition((prev) => ({
|
||||
...prev,
|
||||
x: getDesktopClampedX(prev.x),
|
||||
}));
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!position.show || isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleViewportResize = () => {
|
||||
setPosition((prev) => ({
|
||||
...prev,
|
||||
x: getDesktopClampedX(prev.x),
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleViewportResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleViewportResize);
|
||||
};
|
||||
}, [getDesktopClampedX, isMobile, position.show]);
|
||||
|
||||
const handleSelectionChange = React.useCallback(() => {
|
||||
const selection = window.getSelection();
|
||||
@@ -247,11 +295,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
'px-3 py-2',
|
||||
'safe-area-bottom',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isClosing
|
||||
? 'opacity-0 translate-y-[4px] pointer-events-none'
|
||||
: isOpening
|
||||
? 'opacity-0 translate-y-[4px]'
|
||||
: 'opacity-100 translate-y-0'
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
style={{
|
||||
paddingBottom: 'calc(0.5rem + env(safe-area-inset-bottom, 0px))',
|
||||
@@ -319,16 +363,12 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1',
|
||||
'flex items-center gap-1 whitespace-nowrap',
|
||||
'rounded-lg border border-[var(--interactive-border)]',
|
||||
'bg-[var(--surface-elevated)] shadow-none',
|
||||
'px-1.5 py-1',
|
||||
'transition-[opacity,transform] duration-200 ease-out will-change-[opacity,transform]',
|
||||
isClosing
|
||||
? 'opacity-0 translate-y-[4px] pointer-events-none'
|
||||
: isOpening
|
||||
? 'opacity-0 translate-y-[4px]'
|
||||
: 'opacity-100 translate-y-0'
|
||||
isOpening ? 'opacity-0 translate-y-[4px]' : 'opacity-100 translate-y-0'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
@@ -344,7 +384,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
type="button"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<span>Add to chat</span>
|
||||
<span className="whitespace-nowrap">Add to chat</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-4 bg-[var(--interactive-border)]" />
|
||||
@@ -362,7 +402,7 @@ export const TextSelectionMenu: React.FC<TextSelectionMenuProps> = ({ containerR
|
||||
type="button"
|
||||
>
|
||||
<RiChatNewLine className="h-4 w-4" />
|
||||
<span>New session</span>
|
||||
<span className="whitespace-nowrap">New session</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
|
||||
Reference in New Issue
Block a user