diff --git a/src/__tests__/unit/claude-session-parser.test.ts b/src/__tests__/unit/claude-session-parser.test.ts index b3d94b00..51a5a296 100644 --- a/src/__tests__/unit/claude-session-parser.test.ts +++ b/src/__tests__/unit/claude-session-parser.test.ts @@ -10,12 +10,19 @@ import assert from 'node:assert/strict'; import fs from 'fs'; import path from 'path'; import os from 'os'; +import { pathToFileURL } from 'node:url'; // We test the parser functions by creating temporary JSONL files // that mimic Claude Code's session storage format. const TEST_DIR = path.join(os.tmpdir(), `codepilot-test-sessions-${Date.now()}`); const PROJECTS_DIR = path.join(TEST_DIR, '.claude', 'projects'); +const originalEnv = { + HOME: process.env.HOME, + USERPROFILE: process.env.USERPROFILE, + HOMEDRIVE: process.env.HOMEDRIVE, + HOMEPATH: process.env.HOMEPATH, +}; // Helper to create a JSONL session file function createSessionFile( @@ -121,18 +128,25 @@ describe('claude-session-parser', () => { let parser: typeof import('../../lib/claude-session-parser'); before(async () => { - // Set HOME to our test directory so the parser looks for sessions there + // Point all common home env vars to test dir so os.homedir() is deterministic on Windows/macOS/Linux. + const parsed = path.parse(TEST_DIR); process.env.HOME = TEST_DIR; + process.env.USERPROFILE = TEST_DIR; + process.env.HOMEDRIVE = parsed.root.replace(/[\\\/]$/, ''); + process.env.HOMEPATH = TEST_DIR.slice(parsed.root.length - 1); // Dynamic import - tsx handles the TypeScript + path alias resolution - parser = await import(parserPath); + parser = await import(pathToFileURL(parserPath).href); }); after(() => { // Clean up test directory fs.rmSync(TEST_DIR, { recursive: true, force: true }); - // Restore HOME - process.env.HOME = os.homedir(); + // Restore env + process.env.HOME = originalEnv.HOME; + process.env.USERPROFILE = originalEnv.USERPROFILE; + process.env.HOMEDRIVE = originalEnv.HOMEDRIVE; + process.env.HOMEPATH = originalEnv.HOMEPATH; }); describe('decodeProjectPath', () => { diff --git a/src/components/ai-elements/file-tree.tsx b/src/components/ai-elements/file-tree.tsx index b2b7015b..9d56ebc0 100644 --- a/src/components/ai-elements/file-tree.tsx +++ b/src/components/ai-elements/file-tree.tsx @@ -24,6 +24,10 @@ import { useState, } from "react"; +const FILE_TREE_DRAG_MIME = "application/x-codepilot-path"; +// Keep a text/* fallback because some drag-and-drop consumers strip unknown custom MIME types. +const FILE_TREE_DRAG_FALLBACK_MIME = "text/x-codepilot-path"; + interface FileTreeContextType { expandedPaths: Set; togglePath: (path: string) => void; @@ -132,6 +136,17 @@ export const FileTreeFolder = ({ togglePath(path); }, [togglePath, path]); + const handleDragStart = useCallback( + (e: React.DragEvent) => { + const payload = JSON.stringify({ path, name, type: "directory" }); + e.dataTransfer.setData(FILE_TREE_DRAG_MIME, payload); + e.dataTransfer.setData(FILE_TREE_DRAG_FALLBACK_MIME, payload); + e.dataTransfer.setData("text/plain", path); + e.dataTransfer.effectAllowed = "copy"; + }, + [name, path] + ); + const folderContextValue = useMemo( () => ({ isExpanded, name, path }), [isExpanded, name, path] @@ -148,6 +163,8 @@ export const FileTreeFolder = ({ >
+ + ); + })} +
+ ); +} + export function MessageInput({ onSend, onImageGenerate, @@ -380,6 +475,7 @@ export function MessageInput({ const popoverRef = useRef(null); const searchInputRef = useRef(null); const modelMenuRef = useRef(null); + const dropZoneRef = useRef(null); const [popoverMode, setPopoverMode] = useState(null); const [popoverItems, setPopoverItems] = useState([]); @@ -395,6 +491,76 @@ export function MessageInput({ const [aiSearchLoading, setAiSearchLoading] = useState(false); const aiSearchAbortRef = useRef(null); const aiSearchTimerRef = useRef | null>(null); + const [isDragOver, setIsDragOver] = useState(false); + const [contextMentions, setContextMentions] = useState([]); + + const removeContextMention = useCallback((id: string) => { + setContextMentions((prev) => prev.filter((m) => m.id !== id)); + }, []); + + const addContextMention = useCallback((path: string, name: string, type: 'file' | 'directory') => { + setContextMentions((prev) => { + if (prev.some((m) => m.path === path)) return prev; + return [...prev, { id: nanoid(), path, name, type }]; + }); + setTimeout(() => textareaRef.current?.focus(), 0); + }, []); + + const appendPathMention = useCallback((path: string) => { + setInputValue((prev) => { + const suffix = `@${path} `; + return prev ? `${prev}${suffix}` : suffix; + }); + setTimeout(() => textareaRef.current?.focus(), 0); + }, []); + + useEffect(() => { + const el = dropZoneRef.current; + if (!el) return; + + const onDragOver = (e: DragEvent) => { + const isTreeDrag = hasDragType(e.dataTransfer, FILE_TREE_DRAG_MIME) + || hasDragType(e.dataTransfer, FILE_TREE_DRAG_FALLBACK_MIME); + + if (isTreeDrag) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; + setIsDragOver(true); + } + }; + + const onDragLeave = (e: DragEvent) => { + if (el.contains(e.relatedTarget as Node)) return; + setIsDragOver(false); + }; + + const onDrop = (e: DragEvent) => { + setIsDragOver(false); + const data = readFileTreeDropData(e.dataTransfer); + if (!data) return; + e.preventDefault(); + e.stopPropagation(); + if (data.type === 'file') { + window.dispatchEvent( + new CustomEvent('attach-file-to-chat', { detail: { path: data.path } }) + ); + const mentionName = data.name || data.path.split(/[/\\]/).pop() || data.path; + addContextMention(data.path, mentionName, 'file'); + } else { + addContextMention(data.path, data.name || data.path, 'directory'); + } + }; + + el.addEventListener('dragover', onDragOver); + el.addEventListener('dragleave', onDragLeave); + el.addEventListener('drop', onDrop); + return () => { + el.removeEventListener('dragover', onDragOver); + el.removeEventListener('dragleave', onDragLeave); + el.removeEventListener('drop', onDrop); + }; + }, [addContextMention]); // Fetch provider groups from API const fetchProviderModels = useCallback(() => { @@ -602,7 +768,14 @@ export function MessageInput({ const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent) => { e.preventDefault(); - const content = inputValue.trim(); + const rawContent = inputValue.trim(); + const mentionPrefix = contextMentions.length > 0 + ? contextMentions + .filter((m) => !rawContent.includes(`@${m.path}`)) + .map((m) => `@${m.path}`) + .join(' ') + : ''; + const content = [mentionPrefix, rawContent].filter(Boolean).join(' '); closePopover(); @@ -640,6 +813,7 @@ export function MessageInput({ deleteRefImages(PENDING_KEY); } + setContextMentions([]); setInputValue(''); if (onSend) { onSend(content, files.length > 0 ? files : undefined, IMAGE_AGENT_SYSTEM_PROMPT); @@ -679,6 +853,7 @@ export function MessageInput({ const files = await convertFiles(); setBadge(null); + setContextMentions([]); setInputValue(''); onSend(finalPrompt, files.length > 0 ? files : undefined); return; @@ -724,8 +899,9 @@ export function MessageInput({ } onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined); + setContextMentions([]); setInputValue(''); - }, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, imageGen]); + }, [inputValue, onSend, onImageGenerate, onCommand, disabled, isStreaming, closePopover, badge, imageGen, contextMentions]); const filteredItems = popoverItems.filter((item) => { const q = popoverFilter.toLowerCase(); @@ -908,7 +1084,10 @@ export function MessageInput({ return (
-
+
{/* Popover */} {popoverMode && (allDisplayedItems.length > 0 || aiSearchLoading) && (() => { const builtInItems = filteredItems.filter(item => item.builtIn); @@ -1068,8 +1247,7 @@ export function MessageInput({ accept="" multiple > - {/* Bridge: listens for file tree "+" button events */} - + {/* Command badge */} {badge && (
@@ -1092,6 +1270,7 @@ export function MessageInput({ )} {/* File attachment capsules */} + - - - + 0} + /> + +
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 79e59448..733d30d1 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -47,6 +47,7 @@ const en = { 'messageInput.modeCode': 'Code', 'messageInput.modePlan': 'Plan', 'messageInput.aiSuggested': 'AI Suggested', + 'messageInput.removeContextMention': 'Remove context mention: {name}', // ── Streaming message ─────────────────────────────────────── 'streaming.thinking': 'Thinking...', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 4ad12106..b2c356b0 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -44,6 +44,7 @@ const zh: Record = { 'messageInput.modeCode': '代码', 'messageInput.modePlan': '计划', 'messageInput.aiSuggested': 'AI 推荐', + 'messageInput.removeContextMention': '移除上下文引用:{name}', // ── Streaming message ─────────────────────────────────────── 'streaming.thinking': '思考中...',