mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import React, {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useCallback,
|
|
useLayoutEffect,
|
|
} from 'react';
|
|
import { useVSCode } from './hooks/useVSCode.js';
|
|
import { useSessionManagement } from './hooks/session/useSessionManagement.js';
|
|
import { useFileContext } from './hooks/file/useFileContext.js';
|
|
import { useMessageHandling } from './hooks/message/useMessageHandling.js';
|
|
import { useToolCalls } from './hooks/useToolCalls.js';
|
|
import { useWebViewMessages } from './hooks/useWebViewMessages.js';
|
|
import { useMessageSubmit } from './hooks/useMessageSubmit.js';
|
|
import type {
|
|
PermissionOption,
|
|
ToolCall as PermissionToolCall,
|
|
} from './components/PermissionRequest.js';
|
|
import type { TextMessage } from './hooks/message/useMessageHandling.js';
|
|
import type { ToolCallData } from './components/ToolCall.js';
|
|
import { PermissionDrawer } from './components/PermissionDrawer.js';
|
|
import { ToolCall } from './components/ToolCall.js';
|
|
import { hasToolCallOutput } from './components/toolcalls/shared/utils.js';
|
|
import { InProgressToolCall } from './components/InProgressToolCall.js';
|
|
import { EmptyState } from './components/EmptyState.js';
|
|
import type { PlanEntry } from './components/PlanDisplay.js';
|
|
import { type CompletionItem } from './components/CompletionTypes.js';
|
|
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
|
import { InfoBanner } from './components/InfoBanner.js';
|
|
import { ChatHeader } from './components/layouts/ChatHeader.js';
|
|
import {
|
|
UserMessage,
|
|
AssistantMessage,
|
|
ThinkingMessage,
|
|
WaitingMessage,
|
|
} from './components/messages/index.js';
|
|
import { InputForm } from './components/InputForm.js';
|
|
import { SessionSelector } from './components/session/SessionSelector.js';
|
|
import { FileIcon, UserIcon } from './components/icons/index.js';
|
|
import type { EditMode } from './types/toolCall.js';
|
|
|
|
export const App: React.FC = () => {
|
|
const vscode = useVSCode();
|
|
|
|
// Core hooks
|
|
const sessionManagement = useSessionManagement(vscode);
|
|
const fileContext = useFileContext(vscode);
|
|
const messageHandling = useMessageHandling();
|
|
const {
|
|
inProgressToolCalls,
|
|
completedToolCalls,
|
|
handleToolCallUpdate,
|
|
clearToolCalls,
|
|
} = useToolCalls();
|
|
|
|
// UI state
|
|
const [inputText, setInputText] = useState('');
|
|
const [permissionRequest, setPermissionRequest] = useState<{
|
|
options: PermissionOption[];
|
|
toolCall: PermissionToolCall;
|
|
} | null>(null);
|
|
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
|
const messagesEndRef = useRef<HTMLDivElement>(
|
|
null,
|
|
) as React.RefObject<HTMLDivElement>;
|
|
// Scroll container for message list; used to keep the view anchored to the latest content
|
|
const messagesContainerRef = useRef<HTMLDivElement>(
|
|
null,
|
|
) as React.RefObject<HTMLDivElement>;
|
|
const inputFieldRef = useRef<HTMLDivElement>(
|
|
null,
|
|
) as React.RefObject<HTMLDivElement>;
|
|
const [showBanner, setShowBanner] = useState(true);
|
|
const [editMode, setEditMode] = useState<EditMode>('ask');
|
|
const [thinkingEnabled, setThinkingEnabled] = useState(false);
|
|
const [isComposing, setIsComposing] = useState(false);
|
|
|
|
// Completion system
|
|
const getCompletionItems = React.useCallback(
|
|
async (trigger: '@' | '/', query: string): Promise<CompletionItem[]> => {
|
|
if (trigger === '@') {
|
|
if (!fileContext.hasRequestedFiles) {
|
|
fileContext.requestWorkspaceFiles();
|
|
}
|
|
|
|
const fileIcon = <FileIcon />;
|
|
const allItems: CompletionItem[] = fileContext.workspaceFiles.map(
|
|
(file) => ({
|
|
id: file.id,
|
|
label: file.label,
|
|
description: file.description,
|
|
type: 'file' as const,
|
|
icon: fileIcon,
|
|
// Insert filename after @, keep path for mapping
|
|
value: file.label,
|
|
path: file.path,
|
|
}),
|
|
);
|
|
|
|
if (query && query.length >= 1) {
|
|
fileContext.requestWorkspaceFiles(query);
|
|
const lowerQuery = query.toLowerCase();
|
|
return allItems.filter(
|
|
(item) =>
|
|
item.label.toLowerCase().includes(lowerQuery) ||
|
|
(item.description &&
|
|
item.description.toLowerCase().includes(lowerQuery)),
|
|
);
|
|
}
|
|
|
|
// If first time and still loading, show a placeholder
|
|
if (allItems.length === 0) {
|
|
return [
|
|
{
|
|
id: 'loading-files',
|
|
label: 'Searching files…',
|
|
description: 'Type to filter, or wait a moment…',
|
|
type: 'info' as const,
|
|
},
|
|
];
|
|
}
|
|
|
|
return allItems;
|
|
} else {
|
|
// Handle slash commands
|
|
const commands: CompletionItem[] = [
|
|
{
|
|
id: 'login',
|
|
label: '/login',
|
|
description: 'Login to Qwen Code',
|
|
type: 'command',
|
|
icon: <UserIcon />,
|
|
},
|
|
];
|
|
|
|
return commands.filter((cmd) =>
|
|
cmd.label.toLowerCase().includes(query.toLowerCase()),
|
|
);
|
|
}
|
|
},
|
|
[fileContext],
|
|
);
|
|
|
|
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
|
|
|
|
// When workspace files update while menu open for @, refresh items so the first @ shows the list
|
|
// Note: Avoid depending on the entire `completion` object here, since its identity
|
|
// changes on every render which would retrigger this effect and can cause a refresh loop.
|
|
useEffect(() => {
|
|
if (completion.isOpen && completion.triggerChar === '@') {
|
|
// Only refresh items; do not change other completion state to avoid re-renders loops
|
|
completion.refreshCompletion();
|
|
}
|
|
// Only re-run when the actual data source changes, not on every render
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]);
|
|
|
|
// Message submission
|
|
const handleSubmit = useMessageSubmit({
|
|
inputText,
|
|
setInputText,
|
|
messageHandling,
|
|
fileContext,
|
|
vscode,
|
|
inputFieldRef,
|
|
isStreaming: messageHandling.isStreaming,
|
|
});
|
|
|
|
// Message handling
|
|
useWebViewMessages({
|
|
sessionManagement,
|
|
fileContext,
|
|
messageHandling,
|
|
handleToolCallUpdate,
|
|
clearToolCalls,
|
|
setPlanEntries,
|
|
handlePermissionRequest: setPermissionRequest,
|
|
inputFieldRef,
|
|
setInputText,
|
|
});
|
|
|
|
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
|
// but don't interrupt the user if they scrolled up.
|
|
// We track whether the user is currently "pinned" to the bottom (near the end).
|
|
const [pinnedToBottom, setPinnedToBottom] = useState(true);
|
|
const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 });
|
|
|
|
// Observe scroll position to know if user has scrolled away from the bottom.
|
|
useEffect(() => {
|
|
const container = messagesContainerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
const onScroll = () => {
|
|
// Use a small threshold so slight deltas don't flip the state.
|
|
// Note: there's extra bottom padding for the input area, so keep this a bit generous.
|
|
const threshold = 80; // px tolerance
|
|
const distanceFromBottom =
|
|
container.scrollHeight - (container.scrollTop + container.clientHeight);
|
|
setPinnedToBottom(distanceFromBottom <= threshold);
|
|
};
|
|
|
|
// Initialize once mounted so first render is correct
|
|
onScroll();
|
|
container.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => container.removeEventListener('scroll', onScroll);
|
|
}, []);
|
|
|
|
// When content changes, if the user is pinned to bottom, keep it anchored there.
|
|
// Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates.
|
|
useLayoutEffect(() => {
|
|
const container = messagesContainerRef.current;
|
|
if (!container) {
|
|
return;
|
|
}
|
|
|
|
// Detect whether new items were appended (vs. streaming chunk updates)
|
|
const prev = prevCountsRef.current;
|
|
const newMsg = messageHandling.messages.length > prev.msgLen;
|
|
const newInProg = inProgressToolCalls.length > prev.inProgLen;
|
|
const newDone = completedToolCalls.length > prev.doneLen;
|
|
prevCountsRef.current = {
|
|
msgLen: messageHandling.messages.length,
|
|
inProgLen: inProgressToolCalls.length,
|
|
doneLen: completedToolCalls.length,
|
|
};
|
|
|
|
if (!pinnedToBottom) {
|
|
// Do nothing if user scrolled away; avoid stealing scroll.
|
|
return;
|
|
}
|
|
|
|
const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks
|
|
|
|
// Anchor to the bottom on next frame to avoid layout thrash.
|
|
const raf = requestAnimationFrame(() => {
|
|
const top = container.scrollHeight - container.clientHeight;
|
|
// Use scrollTo to avoid cross-context issues with scrollIntoView.
|
|
container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
|
|
});
|
|
return () => cancelAnimationFrame(raf);
|
|
}, [
|
|
pinnedToBottom,
|
|
messageHandling.messages,
|
|
inProgressToolCalls,
|
|
completedToolCalls,
|
|
messageHandling.isWaitingForResponse,
|
|
messageHandling.loadingMessage,
|
|
messageHandling.isStreaming,
|
|
planEntries,
|
|
]);
|
|
|
|
// When the last rendered item resizes (e.g., images/code blocks load/expand),
|
|
// if we're pinned to bottom, keep it anchored there.
|
|
useEffect(() => {
|
|
const container = messagesContainerRef.current;
|
|
const endEl = messagesEndRef.current;
|
|
if (!container || !endEl) {
|
|
return;
|
|
}
|
|
|
|
const lastItem = endEl.previousElementSibling as HTMLElement | null;
|
|
if (!lastItem) {
|
|
return;
|
|
}
|
|
|
|
let frame = 0;
|
|
const ro = new ResizeObserver(() => {
|
|
if (!pinnedToBottom) {
|
|
return;
|
|
}
|
|
// Defer to next frame to avoid thrash during rapid size changes
|
|
cancelAnimationFrame(frame);
|
|
frame = requestAnimationFrame(() => {
|
|
const top = container.scrollHeight - container.clientHeight;
|
|
container.scrollTo({ top });
|
|
});
|
|
});
|
|
ro.observe(lastItem);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
ro.disconnect();
|
|
};
|
|
}, [
|
|
pinnedToBottom,
|
|
messageHandling.messages,
|
|
inProgressToolCalls,
|
|
completedToolCalls,
|
|
]);
|
|
|
|
// Handle permission response
|
|
const handlePermissionResponse = useCallback(
|
|
(optionId: string) => {
|
|
// Forward the selected optionId directly to extension as ACP permission response
|
|
// Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc.
|
|
vscode.postMessage({
|
|
type: 'permissionResponse',
|
|
data: { optionId },
|
|
});
|
|
setPermissionRequest(null);
|
|
},
|
|
[vscode],
|
|
);
|
|
|
|
// Handle completion selection
|
|
const handleCompletionSelect = useCallback(
|
|
(item: CompletionItem) => {
|
|
// Handle completion selection by inserting the value into the input field
|
|
const inputElement = inputFieldRef.current;
|
|
if (!inputElement) {
|
|
return;
|
|
}
|
|
|
|
// Ignore info items (placeholders like "Searching files…")
|
|
if (item.type === 'info') {
|
|
completion.closeCompletion();
|
|
return;
|
|
}
|
|
|
|
// Slash commands can execute immediately
|
|
if (item.type === 'command') {
|
|
const command = (item.label || '').trim();
|
|
if (command === '/login') {
|
|
vscode.postMessage({ type: 'login', data: {} });
|
|
completion.closeCompletion();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If selecting a file, add @filename -> fullpath mapping
|
|
if (item.type === 'file' && item.value && item.path) {
|
|
try {
|
|
fileContext.addFileReference(item.value, item.path);
|
|
} catch (err) {
|
|
console.warn('[App] addFileReference failed:', err);
|
|
}
|
|
}
|
|
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.rangeCount === 0) {
|
|
return;
|
|
}
|
|
|
|
// Current text and cursor
|
|
const text = inputElement.textContent || '';
|
|
const range = selection.getRangeAt(0);
|
|
|
|
// Compute total text offset for contentEditable
|
|
let cursorPos = text.length;
|
|
if (range.startContainer === inputElement) {
|
|
const childIndex = range.startOffset;
|
|
let offset = 0;
|
|
for (
|
|
let i = 0;
|
|
i < childIndex && i < inputElement.childNodes.length;
|
|
i++
|
|
) {
|
|
offset += inputElement.childNodes[i].textContent?.length || 0;
|
|
}
|
|
cursorPos = offset || text.length;
|
|
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
|
const walker = document.createTreeWalker(
|
|
inputElement,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
);
|
|
let offset = 0;
|
|
let found = false;
|
|
let node: Node | null = walker.nextNode();
|
|
while (node) {
|
|
if (node === range.startContainer) {
|
|
offset += range.startOffset;
|
|
found = true;
|
|
break;
|
|
}
|
|
offset += node.textContent?.length || 0;
|
|
node = walker.nextNode();
|
|
}
|
|
cursorPos = found ? offset : text.length;
|
|
}
|
|
|
|
// Replace from trigger to cursor with selected value
|
|
const textBeforeCursor = text.substring(0, cursorPos);
|
|
const atPos = textBeforeCursor.lastIndexOf('@');
|
|
const slashPos = textBeforeCursor.lastIndexOf('/');
|
|
const triggerPos = Math.max(atPos, slashPos);
|
|
|
|
if (triggerPos >= 0) {
|
|
const insertValue =
|
|
typeof item.value === 'string' ? item.value : String(item.label);
|
|
const newText =
|
|
text.substring(0, triggerPos + 1) + // keep the trigger symbol
|
|
insertValue +
|
|
' ' +
|
|
text.substring(cursorPos);
|
|
|
|
// Update DOM and state, and move caret to end
|
|
inputElement.textContent = newText;
|
|
setInputText(newText);
|
|
|
|
const newRange = document.createRange();
|
|
const sel = window.getSelection();
|
|
newRange.selectNodeContents(inputElement);
|
|
newRange.collapse(false);
|
|
sel?.removeAllRanges();
|
|
sel?.addRange(newRange);
|
|
}
|
|
|
|
// Close the completion menu
|
|
completion.closeCompletion();
|
|
},
|
|
[completion, inputFieldRef, setInputText, fileContext, vscode],
|
|
);
|
|
|
|
// Handle attach context click
|
|
const handleAttachContextClick = useCallback(() => {
|
|
// Open native file picker (different from '@' completion which searches workspace files)
|
|
vscode.postMessage({
|
|
type: 'attachFile',
|
|
data: {},
|
|
});
|
|
}, [vscode]);
|
|
|
|
// Handle toggle edit mode
|
|
const handleToggleEditMode = useCallback(() => {
|
|
setEditMode((prev) => {
|
|
if (prev === 'ask') {
|
|
return 'auto';
|
|
}
|
|
if (prev === 'auto') {
|
|
return 'plan';
|
|
}
|
|
return 'ask';
|
|
});
|
|
}, []);
|
|
|
|
// Handle toggle thinking
|
|
const handleToggleThinking = () => {
|
|
setThinkingEnabled((prev) => !prev);
|
|
};
|
|
|
|
const hasContent =
|
|
messageHandling.messages.length > 0 ||
|
|
messageHandling.isStreaming ||
|
|
inProgressToolCalls.length > 0 ||
|
|
completedToolCalls.length > 0 ||
|
|
planEntries.length > 0;
|
|
|
|
return (
|
|
<div className="chat-container">
|
|
<SessionSelector
|
|
visible={sessionManagement.showSessionSelector}
|
|
sessions={sessionManagement.filteredSessions}
|
|
currentSessionId={sessionManagement.currentSessionId}
|
|
searchQuery={sessionManagement.sessionSearchQuery}
|
|
onSearchChange={sessionManagement.setSessionSearchQuery}
|
|
onSelectSession={(sessionId) => {
|
|
sessionManagement.handleSwitchSession(sessionId);
|
|
sessionManagement.setSessionSearchQuery('');
|
|
}}
|
|
onClose={() => sessionManagement.setShowSessionSelector(false)}
|
|
/>
|
|
|
|
<ChatHeader
|
|
currentSessionTitle={sessionManagement.currentSessionTitle}
|
|
onLoadSessions={sessionManagement.handleLoadQwenSessions}
|
|
onNewSession={sessionManagement.handleNewQwenSession}
|
|
/>
|
|
|
|
<div
|
|
ref={messagesContainerRef}
|
|
className="chat-messages flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb:hover]:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[10px] [&>.message-item]:px-0 [&>.message-item]:py-0 [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
|
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
|
>
|
|
{!hasContent ? (
|
|
<EmptyState />
|
|
) : (
|
|
<>
|
|
{/* Create unified message array containing all types of messages and tool calls */}
|
|
{(() => {
|
|
// Regular messages
|
|
const regularMessages = messageHandling.messages.map((msg) => ({
|
|
type: 'message' as const,
|
|
data: msg,
|
|
timestamp: msg.timestamp,
|
|
}));
|
|
|
|
// In-progress tool calls
|
|
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
|
type: 'in-progress-tool-call' as const,
|
|
data: toolCall,
|
|
timestamp: toolCall.timestamp || Date.now(),
|
|
}));
|
|
|
|
// Completed tool calls
|
|
const completedTools = completedToolCalls
|
|
.filter(hasToolCallOutput)
|
|
.map((toolCall) => ({
|
|
type: 'completed-tool-call' as const,
|
|
data: toolCall,
|
|
timestamp: toolCall.timestamp || Date.now(),
|
|
}));
|
|
|
|
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
|
const allMessages = [
|
|
...regularMessages,
|
|
...inProgressTools,
|
|
...completedTools,
|
|
].sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
|
|
|
|
console.log('[App] allMessages:', allMessages);
|
|
|
|
return allMessages.map((item, index) => {
|
|
switch (item.type) {
|
|
case 'message': {
|
|
const msg = item.data as TextMessage;
|
|
const handleFileClick = (path: string) => {
|
|
vscode.postMessage({
|
|
type: 'openFile',
|
|
data: { path },
|
|
});
|
|
};
|
|
|
|
if (msg.role === 'thinking') {
|
|
return (
|
|
<div key={`message-${index}`} className="message-item">
|
|
<ThinkingMessage
|
|
content={msg.content || ''}
|
|
timestamp={msg.timestamp || 0}
|
|
onFileClick={handleFileClick}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (msg.role === 'user') {
|
|
return (
|
|
<div key={`message-${index}`} className="message-item">
|
|
<UserMessage
|
|
content={msg.content || ''}
|
|
timestamp={msg.timestamp || 0}
|
|
onFileClick={handleFileClick}
|
|
fileContext={msg.fileContext}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={`message-${index}`} className="message-item">
|
|
<AssistantMessage
|
|
content={msg.content || ''}
|
|
timestamp={msg.timestamp || 0}
|
|
onFileClick={handleFileClick}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
case 'in-progress-tool-call':
|
|
return (
|
|
<InProgressToolCall
|
|
key={`in-progress-${(item.data as ToolCallData).toolCallId}`}
|
|
toolCall={item.data as ToolCallData}
|
|
// onFileClick={handleFileClick}
|
|
/>
|
|
);
|
|
|
|
case 'completed-tool-call':
|
|
return (
|
|
<ToolCall
|
|
key={`completed-${(item.data as ToolCallData).toolCallId}`}
|
|
toolCall={item.data as ToolCallData}
|
|
// onFileClick={handleFileClick}
|
|
/>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
});
|
|
})()}
|
|
|
|
{/* Changed to push each plan as a historical toolcall in useWebViewMessages to avoid duplicate display of the latest block */}
|
|
|
|
{messageHandling.isWaitingForResponse &&
|
|
messageHandling.loadingMessage && (
|
|
<WaitingMessage
|
|
loadingMessage={messageHandling.loadingMessage}
|
|
/>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<InfoBanner
|
|
visible={showBanner}
|
|
onDismiss={() => setShowBanner(false)}
|
|
onLinkClick={(e) => {
|
|
e.preventDefault();
|
|
vscode.postMessage({ type: 'openSettings', data: {} });
|
|
}}
|
|
/>
|
|
|
|
<InputForm
|
|
inputText={inputText}
|
|
inputFieldRef={inputFieldRef}
|
|
isStreaming={messageHandling.isStreaming}
|
|
isComposing={isComposing}
|
|
editMode={editMode}
|
|
thinkingEnabled={thinkingEnabled}
|
|
activeFileName={fileContext.activeFileName}
|
|
activeSelection={fileContext.activeSelection}
|
|
onInputChange={setInputText}
|
|
onCompositionStart={() => setIsComposing(true)}
|
|
onCompositionEnd={() => setIsComposing(false)}
|
|
onKeyDown={() => {}}
|
|
onSubmit={handleSubmit.handleSubmit}
|
|
onToggleEditMode={handleToggleEditMode}
|
|
onToggleThinking={handleToggleThinking}
|
|
onFocusActiveEditor={fileContext.focusActiveEditor}
|
|
onShowCommandMenu={async () => {
|
|
if (inputFieldRef.current) {
|
|
inputFieldRef.current.focus();
|
|
|
|
const selection = window.getSelection();
|
|
let position = { top: 0, left: 0 };
|
|
|
|
if (selection && selection.rangeCount > 0) {
|
|
try {
|
|
const range = selection.getRangeAt(0);
|
|
const rangeRect = range.getBoundingClientRect();
|
|
if (rangeRect.top > 0 && rangeRect.left > 0) {
|
|
position = {
|
|
top: rangeRect.top,
|
|
left: rangeRect.left,
|
|
};
|
|
} else {
|
|
const inputRect =
|
|
inputFieldRef.current.getBoundingClientRect();
|
|
position = { top: inputRect.top, left: inputRect.left };
|
|
}
|
|
} catch (error) {
|
|
console.error('[App] Error getting cursor position:', error);
|
|
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
|
position = { top: inputRect.top, left: inputRect.left };
|
|
}
|
|
} else {
|
|
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
|
position = { top: inputRect.top, left: inputRect.left };
|
|
}
|
|
|
|
await completion.openCompletion('/', '', position);
|
|
}
|
|
}}
|
|
onAttachContext={handleAttachContextClick}
|
|
completionIsOpen={completion.isOpen}
|
|
completionItems={completion.items}
|
|
onCompletionSelect={handleCompletionSelect}
|
|
onCompletionClose={completion.closeCompletion}
|
|
/>
|
|
|
|
{permissionRequest && (
|
|
<PermissionDrawer
|
|
isOpen={!!permissionRequest}
|
|
options={permissionRequest.options}
|
|
toolCall={permissionRequest.toolCall}
|
|
onResponse={handlePermissionResponse}
|
|
onClose={() => setPermissionRequest(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Claude-style dropdown is rendered inside InputForm for proper anchoring */}
|
|
</div>
|
|
);
|
|
};
|