From 79143bff4c5bb2ac72d0ba4c0035ae8f48cc42b8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 4 Mar 2026 01:41:01 +0200 Subject: [PATCH] feat: massive chat reliability + UX pass (web/desktop/mobile/vscode) (#593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../provider-logos/bailian-coding-plan.svg | 3 + .../ui/src/components/chat/ChatContainer.tsx | 27 + packages/ui/src/components/chat/ChatInput.tsx | 748 +++++++++++++----- .../ui/src/components/chat/ChatMessage.tsx | 29 +- .../ui/src/components/chat/FileAttachment.tsx | 135 +++- .../chat/FileMentionAutocomplete.tsx | 217 +++-- .../src/components/chat/ServerFilePicker.tsx | 634 --------------- packages/ui/src/components/chat/StatusRow.tsx | 67 +- .../chat/contexts/TurnGroupingContext.tsx | 3 +- .../components/chat/hooks/useTurnGrouping.ts | 5 +- .../components/chat/message/MessageBody.tsx | 115 ++- .../chat/message/TextSelectionMenu.tsx | 118 ++- packages/ui/src/components/layout/Header.tsx | 6 +- .../ui/src/components/layout/MainLayout.tsx | 19 +- .../components/layout/ProjectEditDialog.tsx | 157 +++- .../components/layout/SidebarFilesTree.tsx | 64 +- .../openchamber/OpenChamberVisualSettings.tsx | 16 +- .../sections/projects/ProjectsPage.tsx | 178 ++++- .../session/GitHubIssuePickerDialog.tsx | 84 +- .../session/GitHubPrPickerDialog.tsx | 489 ++++++++++++ .../components/session/NewWorktreeDialog.tsx | 36 +- .../src/components/ui/ContextUsageDisplay.tsx | 9 +- .../ui/src/components/ui/ScrollShadow.tsx | 7 +- .../ui/src/components/ui/UpdateDialog.tsx | 11 +- .../ui/src/components/views/FilesView.tsx | 71 +- packages/ui/src/contexts/DrawerContext.tsx | 3 + .../ui/src/hooks/useChatSearchDirectory.ts | 42 + packages/ui/src/hooks/useDrawerSwipe.ts | 9 +- packages/ui/src/hooks/useMenuActions.ts | 40 +- packages/ui/src/lib/api/types.ts | 2 +- packages/ui/src/lib/appearanceAutoSave.ts | 2 +- packages/ui/src/lib/desktop.ts | 2 +- packages/ui/src/lib/opencode/client.ts | 102 +-- packages/ui/src/lib/persistence.ts | 8 +- packages/ui/src/stores/useFileSearchStore.ts | 12 +- packages/ui/src/stores/useMcpStore.ts | 21 +- packages/ui/src/stores/useUIStore.ts | 4 +- packages/vscode/src/bridge.ts | 36 + packages/vscode/webview/api/files.ts | 38 +- packages/vscode/webview/main.tsx | 30 +- packages/web/server/index.js | 51 +- packages/web/src/api/files.ts | 39 +- 42 files changed, 2212 insertions(+), 1477 deletions(-) create mode 100644 packages/ui/src/assets/provider-logos/bailian-coding-plan.svg delete mode 100644 packages/ui/src/components/chat/ServerFilePicker.tsx create mode 100644 packages/ui/src/components/session/GitHubPrPickerDialog.tsx create mode 100644 packages/ui/src/hooks/useChatSearchDirectory.ts diff --git a/packages/ui/src/assets/provider-logos/bailian-coding-plan.svg b/packages/ui/src/assets/provider-logos/bailian-coding-plan.svg new file mode 100644 index 00000000..b3a2edc3 --- /dev/null +++ b/packages/ui/src/assets/provider-logos/bailian-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a447d0e2..aed1670a 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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) { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index c19d015d..dab1cbe3 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ onOpenSettings, scrollToBo const textareaRef = React.useRef(null); const dropZoneRef = React.useRef(null); const canAcceptDropRef = React.useRef(false); + const nativeDragInsideDropZoneRef = React.useRef(false); const mentionRef = React.useRef(null); const commandRef = React.useRef(null); const skillRef = React.useRef(null); @@ -142,7 +146,6 @@ export const ChatInput: React.FC = ({ 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 = ({ 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(); + 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(null); const abortTimeoutRef = React.useRef | 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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(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 = ({ onOpenSettings, scrollToBo />
- - {isMobile ? null : ( - - - - { - requestAnimationFrame(() => handlePickLocalFiles()); - }} - > - - Attach files - - { - requestAnimationFrame(() => { - setProjectFilePickerOpen(true); - }); - }} - > - - Attach from project - - - + {isVSCode ? ( + + ) : ( + + + + + + { + requestAnimationFrame(() => handlePickLocalFiles()); + }} + > + + Attach files + + { + requestAnimationFrame(() => { + setIssuePickerOpen(true); + }); + }} + > + + Link GitHub Issue + + { + requestAnimationFrame(() => { + setPrPickerOpen(true); + }); + }} + > + + Link GitHub PR + + + + )}
); @@ -2165,67 +2421,104 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo )} - {/* Linked Issue Button - only in draft mode */} - {newSessionDraftOpen && ( + {/* Linked Issue row */} + {linkedIssue && !isVSCode && (
- {linkedIssue ? ( - - ) : ( - - )} + )} + + #{linkedIssue.number} + {linkedIssue.author && ( + by {linkedIssue.author.login} + )} + + + {linkedIssue.title} + + + 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" + > + + + { + 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" + > + + + + +
+ )} + {linkedPr && !isVSCode && ( +
+
)}
= ({ onOpenSettings, scrollToBo : undefined} /> )} -