diff --git a/src/__tests__/e2e/mention-ui.spec.ts b/src/__tests__/e2e/mention-ui.spec.ts new file mode 100644 index 00000000..2f401595 --- /dev/null +++ b/src/__tests__/e2e/mention-ui.spec.ts @@ -0,0 +1,200 @@ +import { test, expect } from '@playwright/test'; +import { goToChat } from '../helpers'; + +test.describe('@mention UI/UX', () => { + test('typing @ keeps input shadow consistent with slash mode', async ({ page }) => { + await goToChat(page); + + const input = page.locator('textarea[name="message"]').first(); + if ((await input.count()) === 0) { + test.skip(true, 'Chat message input is unavailable in current test environment'); + } + await expect(input).toBeVisible(); + + await input.fill('@'); + await expect(input).not.toHaveClass(/bg-primary\/5/); + await expect(input).not.toHaveClass(/border-primary\/20/); + }); + + test('@mentions send structured files/mentions without dumping directory contents', async ({ page }) => { + let chatRequestBody: Record | null = null; + let sessionCounter = 0; + + await page.route('**/api/files/suggest**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + { path: 'src/components', display: 'src/components/', type: 'directory' }, + { path: 'src/app/page.tsx', display: 'src/app/page.tsx', type: 'file' }, + ], + }), + }); + }); + + await page.route('**/api/files/serve**', async (route) => { + await route.fulfill({ + status: 200, + headers: { 'content-type': 'text/plain', 'content-length': '38' }, + body: 'export const page = () => "hello mention";\n', + }); + }); + + await page.route('**/api/files?**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + tree: [ + { name: 'Button.tsx', path: '/tmp/src/components/Button.tsx', type: 'file' }, + { name: 'Dialog.tsx', path: '/tmp/src/components/Dialog.tsx', type: 'file' }, + { name: 'forms', path: '/tmp/src/components/forms', type: 'directory', children: [] }, + ], + }), + }); + }); + + await page.route('**/api/chat/sessions', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + sessionCounter += 1; + const id = `mock-session-${sessionCounter}`; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + session: { + id, + title: 'Mock Session', + model: 'sonnet', + mode: 'code', + provider_id: 'mock', + working_directory: '/tmp', + }, + }), + }); + }); + + await page.route('**/api/chat', async (route) => { + const req = route.request(); + if (req.method() !== 'POST') { + await route.continue(); + return; + } + try { + chatRequestBody = req.postDataJSON() as Record; + } catch { + chatRequestBody = null; + } + await route.fulfill({ + status: 200, + contentType: 'text/event-stream', + body: `data: ${JSON.stringify({ type: 'text', data: 'ok' })}\n\ndata: ${JSON.stringify({ type: 'done' })}\n\n`, + }); + }); + + await goToChat(page); + + const input = page.locator('textarea[name="message"]').first(); + if ((await input.count()) === 0) { + test.skip(true, 'Chat message input is unavailable in current test environment'); + } + await expect(input).toBeVisible(); + + await input.fill('@src/com'); + const dirOption = page.locator('button:has-text("src/components/")').first(); + if ((await dirOption.count()) > 0) { + await dirOption.click(); + await input.type(' and @src/app/page.tsx'); + } else { + test.skip(true, 'Directory mention option is unavailable in current test environment'); + } + await input.press('Enter'); + + await expect.poll(() => chatRequestBody !== null).toBeTruthy(); + + const payload = (chatRequestBody ?? {}) as { files?: unknown; mentions?: unknown; content?: unknown }; + const files = Array.isArray(payload.files) ? payload.files : []; + const mentions = Array.isArray(payload.mentions) ? payload.mentions : []; + const content = typeof payload.content === 'string' ? payload.content : ''; + + expect(files.length).toBeGreaterThanOrEqual(1); + expect(mentions.length).toBeGreaterThanOrEqual(2); + expect(content).toContain('[Referenced Directories]'); + expect(content).toContain('Directory reference @src/components/'); + expect(content).toContain('- Button.tsx'); + expect(content).not.toContain('export const page = () => "hello mention"'); + }); + + test('removing one mention keeps others and chip order follows selection order', async ({ page }) => { + await page.route('**/api/files/suggest**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: [ + { path: 'src/alpha.ts', display: 'src/alpha.ts', type: 'file' }, + { path: 'src/beta.ts', display: 'src/beta.ts', type: 'file' }, + ], + }), + }); + }); + + await goToChat(page); + + const input = page.locator('textarea[name="message"]').first(); + if ((await input.count()) === 0) { + test.skip(true, 'Chat message input is unavailable in current test environment'); + } + await expect(input).toBeVisible(); + + // Create a slash badge first so we can verify mixed chip ordering. + await input.fill('/doctor'); + await input.press('Enter'); + + // Insert two file mentions in order: alpha then beta. + await input.fill('@src/al'); + await page.locator('button:has-text("src/alpha.ts")').first().click(); + await input.type('@src/be'); + await page.locator('button:has-text("src/beta.ts")').first().click(); + + // Selection order should be preserved in chip row: /doctor -> @alpha -> @beta. + const chipsBefore = (await page.locator('span.font-mono').allTextContents()).map((t) => t.trim()).filter(Boolean); + const doctorIdx = chipsBefore.findIndex((t) => t === '/doctor'); + const alphaIdx = chipsBefore.findIndex((t) => t.includes('@src/alpha.ts')); + const betaIdx = chipsBefore.findIndex((t) => t.includes('@src/beta.ts')); + expect(doctorIdx).toBeGreaterThanOrEqual(0); + expect(alphaIdx).toBeGreaterThan(doctorIdx); + expect(betaIdx).toBeGreaterThan(alphaIdx); + + // Remove one mention chip explicitly; the other mention should remain. + await page + .locator('span:has-text("@src/alpha.ts")') + .first() + .locator('button') + .click(); + + const after = await input.inputValue(); + expect(after).not.toContain('@src/alpha.ts'); + expect(after).toContain('@src/beta.ts'); + + const chipsAfter = (await page.locator('span.font-mono').allTextContents()).map((t) => t.trim()).filter(Boolean); + expect(chipsAfter.some((t) => t === '/doctor')).toBeTruthy(); + expect(chipsAfter.some((t) => t.includes('@src/alpha.ts'))).toBeFalsy(); + expect(chipsAfter.some((t) => t.includes('@src/beta.ts'))).toBeTruthy(); + + // Then Backspace should clear the remaining @file token as one unit. + await input.evaluate((el) => { + const ta = el as HTMLTextAreaElement; + const len = ta.value.length; + ta.focus(); + ta.setSelectionRange(len, len); + }); + await input.press('Backspace'); + const afterBackspace = await input.inputValue(); + expect(afterBackspace).not.toContain('@src/beta.ts'); + }); +}); diff --git a/src/__tests__/unit/files-suggest-route.test.ts b/src/__tests__/unit/files-suggest-route.test.ts new file mode 100644 index 00000000..669039e7 --- /dev/null +++ b/src/__tests__/unit/files-suggest-route.test.ts @@ -0,0 +1,72 @@ +import { after, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { NextRequest } from 'next/server'; +import { GET } from '../../app/api/files/suggest/route'; + +const testRoot = path.join(os.homedir(), '.codepilot-test-files-suggest-' + randomUUID()); + +function req(url: string) { + return new NextRequest(url); +} + +after(() => { + try { + fs.rmSync(testRoot, { recursive: true, force: true }); + } catch { + // ignore cleanup errors in CI + } +}); + +describe('/api/files/suggest route', () => { + it('returns 400 when sessionId and workingDirectory are both missing', async () => { + const res = await GET(req('http://localhost/api/files/suggest')); + assert.equal(res.status, 400); + }); + + it('rejects filesystem root workingDirectory', async () => { + const rootPath = path.parse(process.cwd()).root; + const res = await GET( + req(`http://localhost/api/files/suggest?workingDirectory=${encodeURIComponent(rootPath)}`), + ); + assert.equal(res.status, 403); + }); + + it('rejects workingDirectory outside home when sessionId is not provided', async () => { + const outsideHome = process.platform === 'win32' ? 'C:\\Windows' : '/tmp'; + const res = await GET( + req(`http://localhost/api/files/suggest?workingDirectory=${encodeURIComponent(outsideHome)}`), + ); + assert.equal(res.status, 403); + }); + + it('returns relative paths with node type and respects limit', async () => { + fs.mkdirSync(path.join(testRoot, 'src', 'components'), { recursive: true }); + fs.writeFileSync(path.join(testRoot, 'src', 'app.ts'), 'export const app = 1;\n'); + fs.writeFileSync(path.join(testRoot, 'src', 'components', 'Card.tsx'), 'export default function Card(){}\n'); + fs.writeFileSync(path.join(testRoot, 'README.md'), '# test\n'); + + const url = `http://localhost/api/files/suggest?workingDirectory=${encodeURIComponent(testRoot)}&q=src&limit=2`; + const res = await GET(req(url)); + assert.equal(res.status, 200); + + const data = await res.json() as { + items: Array<{ path: string; display: string; type: 'file' | 'directory'; nodeType: 'file' | 'directory' }>; + }; + assert.ok(Array.isArray(data.items)); + assert.ok(data.items.length <= 2); + assert.ok(data.items.length > 0); + + for (const item of data.items) { + assert.ok(!path.isAbsolute(item.path), `expected relative path, got ${item.path}`); + assert.ok(item.type === 'file' || item.type === 'directory'); + assert.equal(item.nodeType, item.type); + if (item.type === 'directory') { + assert.ok(item.display.endsWith('/')); + } + } + }); +}); diff --git a/src/__tests__/unit/message-input-interactions.test.ts b/src/__tests__/unit/message-input-interactions.test.ts index 30314503..470c3d63 100644 --- a/src/__tests__/unit/message-input-interactions.test.ts +++ b/src/__tests__/unit/message-input-interactions.test.ts @@ -26,6 +26,8 @@ import { resolveKeyAction, resolveDirectSlash, buildCliAppend, + parseMentionRefs, + dedupeMentionsByPath, } from '../../lib/message-input-logic'; // ===================================================================== @@ -678,6 +680,47 @@ describe('CLI badge behavior', () => { }); }); +describe('@mention parsing and dedupe', () => { + it('parses file mention with source range', () => { + const refs = parseMentionRefs('Please check @src/app/page.tsx now'); + assert.equal(refs.length, 1); + assert.equal(refs[0].path, 'src/app/page.tsx'); + assert.equal(refs[0].nodeType, 'file'); + assert.ok(refs[0].sourceRange.start >= 0); + assert.ok(refs[0].sourceRange.end > refs[0].sourceRange.start); + }); + + it('parses multiple mentions and keeps order', () => { + const refs = parseMentionRefs('Compare @a.ts and @b.ts'); + assert.equal(refs.length, 2); + assert.equal(refs[0].path, 'a.ts'); + assert.equal(refs[1].path, 'b.ts'); + }); + + it('respects node type lookup for directory mentions', () => { + const refs = parseMentionRefs( + 'Open @src/components', + { 'src/components': 'directory' }, + ); + assert.equal(refs.length, 1); + assert.equal(refs[0].nodeType, 'directory'); + }); + + it('strips trailing punctuation from mention path', () => { + const refs = parseMentionRefs('See @src/index.ts, please.'); + assert.equal(refs.length, 1); + assert.equal(refs[0].path, 'src/index.ts'); + }); + + it('dedupes mentions by path', () => { + const refs = parseMentionRefs('@src/a.ts @src/a.ts @src/b.ts'); + const deduped = dedupeMentionsByPath(refs); + assert.equal(deduped.length, 2); + assert.equal(deduped[0].path, 'src/a.ts'); + assert.equal(deduped[1].path, 'src/b.ts'); + }); +}); + // --- Cross-cutting: full keyboard interaction scenarios --------------- describe('Full keyboard interaction scenarios', () => { diff --git a/src/app/api/files/suggest/route.ts b/src/app/api/files/suggest/route.ts new file mode 100644 index 00000000..db84d3b3 --- /dev/null +++ b/src/app/api/files/suggest/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server'; +import path from 'path'; +import os from 'os'; +import { getSession } from '@/lib/db'; +import { scanDirectory, isPathSafe, isRootPath } from '@/lib/files'; +import type { MentionNodeType } from '@/types'; + +interface SuggestItem { + path: string; + display: string; + type: MentionNodeType; + nodeType: MentionNodeType; +} + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; +const SCAN_DEPTH = 4; + +function normalizeRelPath(input: string): string { + return input.replace(/\\/g, '/').replace(/^\/+/, ''); +} + +function flattenTree( + nodes: Array<{ name: string; path: string; type: 'file' | 'directory'; children?: unknown[] }>, + baseDir: string, + out: SuggestItem[], +) { + for (const node of nodes) { + const rel = normalizeRelPath(path.relative(baseDir, node.path)); + if (!rel) continue; + const nodeType: MentionNodeType = node.type === 'directory' ? 'directory' : 'file'; + out.push({ + path: rel, + display: nodeType === 'directory' ? `${rel}/` : rel, + type: nodeType, + nodeType, + }); + if (node.type === 'directory' && node.children) { + flattenTree(node.children as typeof nodes, baseDir, out); + } + } +} + +function score(item: SuggestItem, q: string): number { + const candidate = item.path.toLowerCase(); + if (!q) return 0; + if (candidate === q) return 0; + if (candidate.startsWith(q)) return 1; + const slashIdx = candidate.lastIndexOf('/'); + const basename = slashIdx >= 0 ? candidate.slice(slashIdx + 1) : candidate; + if (basename === q) return 2; + if (basename.startsWith(q)) return 3; + return 10; +} + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const sessionId = searchParams.get('sessionId') || ''; + const workingDirectory = searchParams.get('workingDirectory') || ''; + const query = (searchParams.get('q') || '').trim().toLowerCase(); + const limitRaw = Number.parseInt(searchParams.get('limit') || '', 10); + const limit = Number.isFinite(limitRaw) + ? Math.max(1, Math.min(limitRaw, MAX_LIMIT)) + : DEFAULT_LIMIT; + + let baseDir = ''; + if (sessionId) { + const session = getSession(sessionId); + if (!session?.working_directory) { + return NextResponse.json({ error: 'Session not found' }, { status: 404 }); + } + baseDir = path.resolve(session.working_directory); + } else if (workingDirectory) { + baseDir = path.resolve(workingDirectory); + } else { + return NextResponse.json({ error: 'Missing sessionId or workingDirectory' }, { status: 400 }); + } + + if (isRootPath(baseDir)) { + return NextResponse.json({ error: 'Invalid working directory' }, { status: 403 }); + } + if (!sessionId) { + const homeDir = os.homedir(); + if (!isPathSafe(homeDir, baseDir)) { + return NextResponse.json({ error: 'Working directory is outside the allowed scope' }, { status: 403 }); + } + } + + const tree = await scanDirectory(baseDir, SCAN_DEPTH); + const all: SuggestItem[] = []; + flattenTree(tree, baseDir, all); + + const filtered = all + .filter((item) => { + if (!query) return true; + const p = item.path.toLowerCase(); + const d = item.display.toLowerCase(); + return p.includes(query) || d.includes(query); + }) + .sort((a, b) => { + const scoreDiff = score(a, query) - score(b, query); + if (scoreDiff !== 0) return scoreDiff; + if (a.nodeType !== b.nodeType) return a.nodeType === 'directory' ? -1 : 1; + return a.path.localeCompare(b.path); + }) + .slice(0, limit); + + return NextResponse.json({ items: filtered, root: baseDir }); +} diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index a037e096..271678b8 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types'; +import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent, FileAttachment, MentionRef } from '@/types'; import { MessageList } from '@/components/chat/MessageList'; import { MessageInput } from '@/components/chat/MessageInput'; import { ChatComposerActionBar } from '@/components/chat/ChatComposerActionBar'; @@ -427,7 +427,7 @@ export default function NewChatPage() { }, [pendingPermission, setPendingApprovalSessionId]); const sendFirstMessage = useCallback( - async (content: string, _files?: unknown, systemPromptAppend?: string, displayOverride?: string) => { + async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string, mentions?: MentionRef[]) => { if (isStreaming) return; // Wait for model/provider to be resolved from the global default before allowing send @@ -489,11 +489,15 @@ export default function NewChatPage() { window.dispatchEvent(new CustomEvent('session-created')); // Add user message to UI — use displayOverride for chat bubble if provided + const displayUserContent = displayOverride || content; + const contentWithFileMeta = files && files.length > 0 + ? `${displayUserContent}` + : displayUserContent; const userMessage: Message = { id: 'temp-' + Date.now(), session_id: session.id, role: 'user', - content: displayOverride || content, + content: contentWithFileMeta, created_at: new Date().toISOString(), token_usage: null, }; @@ -514,6 +518,8 @@ export default function NewChatPage() { mode, model: currentModel, provider_id: currentProviderId, + ...(files && files.length > 0 ? { files } : {}), + ...(mentions && mentions.length > 0 ? { mentions } : {}), ...(systemPromptAppend ? { systemPromptAppend } : {}), ...(selectedEffort ? { effort: selectedEffort } : {}), ...(thinkingConfig ? { thinking: thinkingConfig } : {}), diff --git a/src/components/chat/ChatView.tsx b/src/components/chat/ChatView.tsx index 6f4d8fc5..83e5f326 100644 --- a/src/components/chat/ChatView.tsx +++ b/src/components/chat/ChatView.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { useRouter } from 'next/navigation'; -import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot } from '@/types'; +import type { Message, MessagesResponse, FileAttachment, SessionStreamSnapshot, MentionRef } from '@/types'; import { MessageList } from './MessageList'; import { MessageInput } from './MessageInput'; import { ChatComposerActionBar } from './ChatComposerActionBar'; @@ -34,6 +34,7 @@ interface QueuedMessage { files?: FileAttachment[]; systemPromptAppend?: string; displayOverride?: string; + mentions?: MentionRef[]; } interface ChatViewProps { @@ -198,7 +199,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Pending image generation notices const pendingImageNoticesRef = useRef([]); - const sendMessageRef = useRef<(content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => Promise>(undefined); + const sendMessageRef = useRef<(content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string, mentions?: MentionRef[]) => Promise>(undefined); const initMetaRef = useRef<{ tools?: unknown; slash_commands?: unknown; skills?: unknown } | null>(null); const handleModeChange = useCallback((newMode: string) => { @@ -428,7 +429,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal /** Start an API stream for the given content. Does NOT add a user message to the list. */ const doStartStream = useCallback( - (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => { + (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string, mentions?: MentionRef[]) => { const notices = pendingImageNoticesRef.current.length > 0 ? [...pendingImageNoticesRef.current] : undefined; @@ -447,6 +448,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal thinking: buildThinkingConfig(), context1m, displayOverride, + mentions, onModeChanged: (sdkMode) => { const uiMode = sdkMode === 'plan' ? 'plan' : 'code'; handleModeChange(uiMode); @@ -464,7 +466,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal ); const sendMessage = useCallback( - async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => { + async (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string, mentions?: MentionRef[]) => { const displayUserContent = displayOverride || content; let displayContent = displayUserContent; if (files && files.length > 0) { @@ -474,7 +476,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal // Queue message if currently streaming — hold above input, send after completion if (isStreaming) { - setMessageQueue((prev) => [...prev, { content, files, systemPromptAppend, displayOverride }]); + setMessageQueue((prev) => [...prev, { content, files, systemPromptAppend, displayOverride, mentions }]); return; } @@ -487,7 +489,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal token_usage: null, }; cappedSetMessages((prev) => [...prev, userMessage]); - doStartStream(content, files, systemPromptAppend, displayOverride); + doStartStream(content, files, systemPromptAppend, displayOverride, mentions); }, [sessionId, isStreaming, doStartStream, cappedSetMessages] ); @@ -516,7 +518,7 @@ export function ChatView({ sessionId, initialMessages = [], initialHasMore = fal token_usage: null, }; cappedSetMessages((prev) => [...prev, userMessage]); - doStartStream(next.content, next.files, next.systemPromptAppend, next.displayOverride); + doStartStream(next.content, next.files, next.systemPromptAppend, next.displayOverride, next.mentions); } if (isStreaming) { dequeuingRef.current = false; diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index f0ed151a..07276ab5 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRef, useState, useCallback, useEffect, type KeyboardEvent, type FormEvent } from 'react'; +import { useRef, useState, useCallback, useEffect, useMemo, type KeyboardEvent, type FormEvent } from 'react'; import { Terminal } from "@/components/ui/icon"; import { useTranslation } from '@/hooks/useTranslation'; import type { TranslationKey } from '@/i18n'; @@ -12,13 +12,13 @@ import { PromptInputButton, } from '@/components/ai-elements/prompt-input'; import type { ChatStatus } from 'ai'; -import type { FileAttachment } from '@/types'; +import type { FileAttachment, MentionRef } from '@/types'; import { SlashCommandButton } from './SlashCommandButton'; import { SlashCommandPopover } from './SlashCommandPopover'; import { CliToolsPopover } from './CliToolsPopover'; import { ModelSelectorDropdown } from './ModelSelectorDropdown'; import { EffortSelectorDropdown } from './EffortSelectorDropdown'; -import { FileAwareSubmitButton, AttachFileButton, FileTreeAttachmentBridge, FileAttachmentsCapsules, CommandBadgeList, CliBadge } from './MessageInputParts'; +import { FileAwareSubmitButton, AttachFileButton, FileTreeAttachmentBridge, FileAttachmentsCapsules, CliBadge, ComposerBadgeRow } from './MessageInputParts'; import { Tooltip, TooltipContent, @@ -33,11 +33,16 @@ import { useProviderModels } from '@/hooks/useProviderModels'; import { useCommandBadge } from '@/hooks/useCommandBadge'; import { useCliToolsFetch } from '@/hooks/useCliToolsFetch'; import { useSlashCommands } from '@/hooks/useSlashCommands'; -import { resolveKeyAction, cycleIndex, resolveDirectSlash, dispatchBadge, buildCliAppend } from '@/lib/message-input-logic'; +import { resolveKeyAction, cycleIndex, resolveDirectSlash, dispatchBadge, buildCliAppend, parseMentionRefs, dedupeMentionsByPath } from '@/lib/message-input-logic'; import { QuickActions } from './QuickActions'; +const MAX_MENTION_FILE_BYTES = 256 * 1024; // 256KB per @file mention +const MAX_MENTION_FILE_COUNT = 6; +const MAX_DIRECTORY_MENTION_COUNT = 3; +const MAX_DIRECTORY_PREVIEW_ITEMS = 30; + interface MessageInputProps { - onSend: (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string) => void; + onSend: (content: string, files?: FileAttachment[], systemPromptAppend?: string, displayOverride?: string, mentions?: MentionRef[]) => void; onCommand?: (command: string) => void; onStop?: () => void; disabled?: boolean; @@ -62,6 +67,39 @@ interface MessageInputProps { hasMessages?: boolean; } +function joinPath(base: string, rel: string): string { + const b = base.replace(/[\\/]+$/, ''); + const r = rel.replace(/^[\\/]+/, ''); + return `${b}/${r}`; +} + +function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode(...chunk); + } + return btoa(binary); +} + +async function fileResponseToAttachment( + response: Response, + filename: string, + idPrefix: string, +): Promise { + const mimeType = response.headers.get('content-type') || 'application/octet-stream'; + const buffer = await response.arrayBuffer(); + return { + id: `${idPrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: filename, + type: mimeType, + size: buffer.byteLength, + data: arrayBufferToBase64(buffer), + }; +} + export function MessageInput({ onSend, onCommand, @@ -93,6 +131,10 @@ export function MessageInput({ if (initialValue) return initialValue; try { return sessionStorage.getItem(draftKey) || ''; } catch { return ''; } }); + const [mentionNodeTypes, setMentionNodeTypes] = useState>({}); + const [badgeOrder, setBadgeOrder] = useState>({}); + const [mentionOrder, setMentionOrder] = useState>({}); + const orderSeqRef = useRef(0); const setInputValue = useCallback((v: string | ((prev: string) => string)) => { setInputValueRaw((prev) => { const next = typeof v === 'function' ? v(prev) : v; @@ -101,6 +143,30 @@ export function MessageInput({ }); }, [draftKey]); + const mentions = useMemo(() => { + // Render chips only for explicitly inserted/known mentions. + return parseMentionRefs(inputValue, mentionNodeTypes).filter((m) => !!mentionNodeTypes[m.path]); + }, [inputValue, mentionNodeTypes]); + + const nextOrder = useCallback(() => { + orderSeqRef.current += 1; + return orderSeqRef.current; + }, []); + + const ensureBadgeOrder = useCallback((command: string) => { + setBadgeOrder((prev) => { + if (prev[command]) return prev; + return { ...prev, [command]: nextOrder() }; + }); + }, [nextOrder]); + + const ensureMentionOrder = useCallback((path: string) => { + setMentionOrder((prev) => { + if (prev[path]) return prev; + return { ...prev, [path]: nextOrder() }; + }); + }, [nextOrder]); + // --- Extracted hooks --- const popover = usePopoverState(modelName); const { providerGroups, currentProviderIdValue, modelOptions, currentModelOption, globalDefaultModel, globalDefaultProvider } = useProviderModels(providerId, modelName); @@ -121,6 +187,23 @@ export function MessageInput({ }, [modelName, modelOptions, currentProviderIdValue, onModelChange, onProviderModelChange]); const { badges, addBadge, removeBadge, clearBadges, cliBadge, setCliBadge, removeCliBadge, hasBadge } = useCommandBadge(textareaRef); + const addBadgeWithOrder = useCallback((badge: { command: string; label: string; description: string; kind: 'agent_skill' | 'slash_command' | 'sdk_command' | 'codepilot_command'; installedSource?: 'agents' | 'claude' }) => { + ensureBadgeOrder(badge.command); + addBadge(badge); + }, [addBadge, ensureBadgeOrder]); + const removeBadgeWithOrder = useCallback((command: string) => { + removeBadge(command); + setBadgeOrder((prev) => { + if (!prev[command]) return prev; + const next = { ...prev }; + delete next[command]; + return next; + }); + }, [removeBadge]); + const clearBadgesWithOrder = useCallback(() => { + clearBadges(); + setBadgeOrder({}); + }, [clearBadges]); const cliToolsFetch = useCliToolsFetch({ popoverMode: popover.popoverMode, @@ -152,7 +235,11 @@ export function MessageInput({ setTriggerPos: popover.setTriggerPos, closePopover: popover.closePopover, onCommand, - addBadge, + addBadge: addBadgeWithOrder, + onMentionInserted: (mention) => { + setMentionNodeTypes((prev) => ({ ...prev, [mention.path]: mention.nodeType })); + ensureMentionOrder(mention.path); + }, isStreaming: !!isStreaming, }); @@ -171,6 +258,8 @@ export function MessageInput({ const filePath = (e as CustomEvent<{ path: string }>).detail?.path; if (!filePath) return; const mention = `@${filePath} `; + setMentionNodeTypes((prev) => ({ ...prev, [filePath]: 'file' })); + ensureMentionOrder(filePath); setInputValue((prev) => { const needsSpace = prev.length > 0 && !prev.endsWith(' ') && !prev.endsWith('\n'); return prev + (needsSpace ? ' ' : '') + mention; @@ -179,7 +268,74 @@ export function MessageInput({ }; window.addEventListener('insert-file-mention', handler); return () => window.removeEventListener('insert-file-mention', handler); - }, [setInputValue]); + }, [setInputValue, setMentionNodeTypes, ensureMentionOrder]); + + const normalizeMentionPath = useCallback((rawPath: string): string => { + const normalizedRaw = rawPath.replace(/\\/g, '/').replace(/^\/+/, ''); + if (!workingDirectory) return normalizedRaw; + const normalizedBase = workingDirectory.replace(/\\/g, '/').replace(/\/+$/, ''); + if (normalizedRaw.startsWith(normalizedBase + '/')) { + return normalizedRaw.slice(normalizedBase.length + 1); + } + return normalizedRaw; + }, [workingDirectory]); + + const fetchMentionFileAttachment = useCallback(async (mentionPath: string): Promise<{ attachment: FileAttachment | null; limitNote?: string }> => { + const safePath = normalizeMentionPath(mentionPath); + const filename = safePath.split('/').filter(Boolean).pop() || 'file'; + try { + if (sessionId) { + const res = await fetch(`/api/files/serve?sessionId=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(safePath)}`); + if (!res.ok) return { attachment: null }; + const headerSize = Number.parseInt(res.headers.get('content-length') || '', 10); + if (Number.isFinite(headerSize) && headerSize > MAX_MENTION_FILE_BYTES) { + return { attachment: null, limitNote: `@${safePath}: omitted (file too large > 256KB).` }; + } + const attachment = await fileResponseToAttachment(res, filename, 'mention'); + if (attachment.size > MAX_MENTION_FILE_BYTES) { + return { attachment: null, limitNote: `@${safePath}: omitted (file too large > 256KB).` }; + } + return { attachment }; + } + + if (!workingDirectory) return { attachment: null }; + const absolutePath = joinPath(workingDirectory, safePath); + const res = await fetch(`/api/files/raw?path=${encodeURIComponent(absolutePath)}`); + if (!res.ok) return { attachment: null }; + const headerSize = Number.parseInt(res.headers.get('content-length') || '', 10); + if (Number.isFinite(headerSize) && headerSize > MAX_MENTION_FILE_BYTES) { + return { attachment: null, limitNote: `@${safePath}: omitted (file too large > 256KB).` }; + } + const attachment = await fileResponseToAttachment(res, filename, 'mention'); + if (attachment.size > MAX_MENTION_FILE_BYTES) { + return { attachment: null, limitNote: `@${safePath}: omitted (file too large > 256KB).` }; + } + return { attachment }; + } catch { + return { attachment: null }; + } + }, [sessionId, workingDirectory, normalizeMentionPath]); + + const fetchDirectorySummary = useCallback(async (mentionPath: string): Promise => { + if (!workingDirectory) return null; + const safePath = normalizeMentionPath(mentionPath); + const dir = joinPath(workingDirectory, safePath); + try { + const res = await fetch(`/api/files?dir=${encodeURIComponent(dir)}&baseDir=${encodeURIComponent(workingDirectory)}&depth=2`); + if (!res.ok) return null; + const data = await res.json(); + const tree = Array.isArray(data.tree) ? data.tree : []; + const preview = tree.slice(0, MAX_DIRECTORY_PREVIEW_ITEMS).map((node: { name: string; type: 'file' | 'directory' }) => ( + node.type === 'directory' ? `- ${node.name}/` : `- ${node.name}` + )); + const extra = tree.length > MAX_DIRECTORY_PREVIEW_ITEMS + ? `\n- ... (${tree.length - MAX_DIRECTORY_PREVIEW_ITEMS} more)` + : ''; + return `Directory reference @${safePath}/\n${preview.join('\n')}${extra}`; + } catch { + return null; + } + }, [workingDirectory, normalizeMentionPath]); const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent) => { e.preventDefault(); @@ -208,13 +364,65 @@ export function MessageInput({ return attachments; }; + const resolveMentionPayload = async () => { + // Only treat mentions inserted/confirmed by the picker (or file-tree bridge) + // as structured mentions. Plain typed "@foo" should remain plain text. + const parsedMentions = parseMentionRefs(inputValue, mentionNodeTypes) + .filter((m) => !!mentionNodeTypes[m.path]); + const dedupedMentions = dedupeMentionsByPath(parsedMentions); + if (dedupedMentions.length === 0) { + return { + mentions: [] as MentionRef[], + files: [] as FileAttachment[], + directoryNotes: [] as string[], + limitNotes: [] as string[], + }; + } + + const mentionFiles: FileAttachment[] = []; + const directoryNotes: string[] = []; + const limitNotes: string[] = []; + let usedDirectoryMentions = 0; + for (const mention of dedupedMentions) { + if (mention.nodeType === 'directory') { + if (usedDirectoryMentions >= MAX_DIRECTORY_MENTION_COUNT) { + limitNotes.push(`@${mention.path}/: omitted (max ${MAX_DIRECTORY_MENTION_COUNT} directories per message).`); + continue; + } + const summary = await fetchDirectorySummary(mention.path); + if (summary) directoryNotes.push(summary); + usedDirectoryMentions += 1; + continue; + } + if (mentionFiles.length >= MAX_MENTION_FILE_COUNT) { + limitNotes.push(`@${mention.path}: omitted (max ${MAX_MENTION_FILE_COUNT} files per message).`); + continue; + } + const { attachment, limitNote } = await fetchMentionFileAttachment(mention.path); + if (attachment) mentionFiles.push(attachment); + if (limitNote) limitNotes.push(limitNote); + } + return { mentions: dedupedMentions, files: mentionFiles, directoryNotes, limitNotes }; + }; + // If Image Agent toggle is on and no badge, send via normal LLM with systemPromptAppend. // PENDING_KEY is a global singleton — queuing would misattach refs, so block entirely // during streaming rather than letting it fall through to the plain queue path. if (imageGen.state.enabled && badges.length === 0) { if (isStreaming) return; // silently block — can't safely queue image-agent prompts - const files = await convertFiles(); - if (!content && files.length === 0) return; + const uploadedFiles = await convertFiles(); + const mentionPayload = await resolveMentionPayload(); + const files = [...uploadedFiles, ...mentionPayload.files]; + const mentionSections: string[] = []; + if (mentionPayload.directoryNotes.length > 0) { + mentionSections.push(`[Referenced Directories]\n${mentionPayload.directoryNotes.join('\n\n')}`); + } + if (mentionPayload.limitNotes.length > 0) { + mentionSections.push(`[Mention Limits]\n${mentionPayload.limitNotes.map((x) => `- ${x}`).join('\n')}`); + } + const mentionAppend = mentionSections.length > 0 ? `\n\n${mentionSections.join('\n\n')}` : ''; + const finalContent = `${content}${mentionAppend}`.trim(); + if (!finalContent && files.length === 0) return; // Store uploaded images as pending reference images for ImageGenConfirmation const imageFiles = files.filter(f => f.type.startsWith('image/')); @@ -226,7 +434,13 @@ export function MessageInput({ setInputValue(''); if (onSend) { - onSend(content, files.length > 0 ? files : undefined, IMAGE_AGENT_SYSTEM_PROMPT); + onSend( + finalContent, + files.length > 0 ? files : undefined, + IMAGE_AGENT_SYSTEM_PROMPT, + mentionPayload.mentions.length > 0 ? content : undefined, + mentionPayload.mentions.length > 0 ? mentionPayload.mentions : undefined, + ); } return; } @@ -235,22 +449,50 @@ export function MessageInput({ // Block during streaming — badges carry slash/skill semantics, not safe to queue. if (badges.length > 0) { if (isStreaming) return; - const files = await convertFiles(); + const uploadedFiles = await convertFiles(); + const mentionPayload = await resolveMentionPayload(); + const files = [...uploadedFiles, ...mentionPayload.files]; const { prompt, displayLabel } = dispatchBadge(badges, content); - clearBadges(); + const mentionSections: string[] = []; + if (mentionPayload.directoryNotes.length > 0) { + mentionSections.push(`[Referenced Directories]\n${mentionPayload.directoryNotes.join('\n\n')}`); + } + if (mentionPayload.limitNotes.length > 0) { + mentionSections.push(`[Mention Limits]\n${mentionPayload.limitNotes.map((x) => `- ${x}`).join('\n')}`); + } + const mentionAppend = mentionSections.length > 0 ? `\n\n${mentionSections.join('\n\n')}` : ''; + const finalPrompt = `${prompt}${mentionAppend}`.trim(); + clearBadgesWithOrder(); setInputValue(''); - onSend(prompt, files.length > 0 ? files : undefined, undefined, displayLabel); + onSend( + finalPrompt, + files.length > 0 ? files : undefined, + undefined, + displayLabel, + mentionPayload.mentions.length > 0 ? mentionPayload.mentions : undefined, + ); return; } - const files = await convertFiles(); + const uploadedFiles = await convertFiles(); + const mentionPayload = await resolveMentionPayload(); + const files = [...uploadedFiles, ...mentionPayload.files]; + const mentionSections: string[] = []; + if (mentionPayload.directoryNotes.length > 0) { + mentionSections.push(`[Referenced Directories]\n${mentionPayload.directoryNotes.join('\n\n')}`); + } + if (mentionPayload.limitNotes.length > 0) { + mentionSections.push(`[Mention Limits]\n${mentionPayload.limitNotes.map((x) => `- ${x}`).join('\n')}`); + } + const mentionAppend = mentionSections.length > 0 ? `\n\n${mentionSections.join('\n\n')}` : ''; + const finalContent = `${content}${mentionAppend}`.trim(); const hasFiles = files.length > 0; - if ((!content && !hasFiles) || disabled) return; + if ((!finalContent && !hasFiles) || disabled) return; // Check if it's a direct slash command typed in the input. if (!hasFiles) { - const slashResult = resolveDirectSlash(content); + const slashResult = resolveDirectSlash(finalContent); if (slashResult.action === 'immediate_command' || slashResult.action === 'set_badge' || slashResult.action === 'unknown_slash_badge') { // Slash commands must NOT execute or queue during streaming — // destructive commands (e.g. /clear) would race with the active stream. @@ -262,7 +504,7 @@ export function MessageInput({ return; } } else { - addBadge(slashResult.badge!); + addBadgeWithOrder(slashResult.badge!); setInputValue(''); return; } @@ -273,12 +515,61 @@ export function MessageInput({ const cliAppend = buildCliAppend(cliBadge); if (cliBadge) setCliBadge(null); - onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined, cliAppend); + const displayOverride = mentionPayload.mentions.length > 0 ? content : undefined; + onSend( + finalContent || 'Please review the attached file(s).', + hasFiles ? files : undefined, + cliAppend, + displayOverride, + mentionPayload.mentions.length > 0 ? mentionPayload.mentions : undefined, + ); setInputValue(''); - }, [inputValue, onSend, onCommand, disabled, isStreaming, popover, badges, cliBadge, imageGen, addBadge, clearBadges, setCliBadge, setInputValue]); + }, [inputValue, mentionNodeTypes, onSend, onCommand, disabled, isStreaming, popover, badges, cliBadge, imageGen, addBadgeWithOrder, clearBadgesWithOrder, setCliBadge, setInputValue, fetchDirectorySummary, fetchMentionFileAttachment]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { + // Mention token behavior: one Backspace removes the whole @path token. + if (e.key === 'Backspace') { + const ta = textareaRef.current; + const start = ta?.selectionStart ?? 0; + const end = ta?.selectionEnd ?? 0; + if (start === end && start > 0) { + const before = inputValue.slice(0, start); + const tokenMatch = before.match(/(^|\s)@([^\s@]+)\s$/) || before.match(/(^|\s)@([^\s@]+)$/); + if (tokenMatch) { + const mentionPath = (tokenMatch[2] || '').replace(/[.,!?;:)\]}]+$/, ''); + if (mentionPath && mentionNodeTypes[mentionPath]) { + e.preventDefault(); + const boundaryLen = (tokenMatch[1] || '').length; + const mentionStart = start - tokenMatch[0].length + boundaryLen; + const mentionEnd = start; + const next = `${inputValue.slice(0, mentionStart)}${inputValue.slice(mentionEnd)}`.replace(/\s{2,}/g, ' '); + const stillHasSamePath = parseMentionRefs(next).some((m) => m.path === mentionPath); + setInputValue(next); + if (!stillHasSamePath) { + setMentionNodeTypes((prev) => { + const updated = { ...prev }; + delete updated[mentionPath]; + return updated; + }); + setMentionOrder((prev) => { + const updated = { ...prev }; + delete updated[mentionPath]; + return updated; + }); + } + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + const pos = Math.max(0, Math.min(mentionStart, next.length)); + el.setSelectionRange(pos, pos); + }); + return; + } + } + } + } + const action = resolveKeyAction(e.key, { popoverMode: popover.popoverMode, popoverHasItems: popover.popoverItems.length > 0, @@ -311,7 +602,7 @@ export function MessageInput({ e.preventDefault(); // Backspace/Escape pops the most recently added badge; matches the // mental model of "undo my last selection". - if (badges.length > 0) removeBadge(badges[badges.length - 1].command); + if (badges.length > 0) removeBadgeWithOrder(badges[badges.length - 1].command); return; case 'remove_cli_badge': @@ -348,9 +639,48 @@ export function MessageInput({ } } }, - [popover, slashCommands, cliToolsFetch, badges, cliBadge, inputValue, removeBadge, removeCliBadge] + [popover, slashCommands, cliToolsFetch, badges, cliBadge, inputValue, mentionNodeTypes, removeBadgeWithOrder, removeCliBadge, setInputValue] ); + const uniqueMentions = useMemo(() => dedupeMentionsByPath(mentions), [mentions]); + const removeMention = useCallback((targetMention: MentionRef) => { + let removedPath = ''; + let stillHasSamePath = false; + setInputValue((prev) => { + const parsed = parseMentionRefs(prev, mentionNodeTypes); + const exact = parsed.find((m) => + m.path === targetMention.path + && m.sourceRange?.start === targetMention.sourceRange?.start + && m.sourceRange?.end === targetMention.sourceRange?.end + ); + const target = exact || parsed.find((m) => m.path === targetMention.path); + if (!target?.sourceRange) return prev; + removedPath = target.path; + const { start, end } = target.sourceRange; + const before = prev.slice(0, start); + let after = prev.slice(end); + if (before.endsWith(' ') && after.startsWith(' ')) after = after.slice(1); + const next = `${before}${after}`.replace(/\s{2,}/g, ' ').trimStart(); + stillHasSamePath = parseMentionRefs(next).some((m) => m.path === target.path); + return next; + }); + if (!removedPath) return; + if (!stillHasSamePath) { + setMentionNodeTypes((prev) => { + if (!prev[removedPath]) return prev; + const next = { ...prev }; + delete next[removedPath]; + return next; + }); + setMentionOrder((prev) => { + if (!prev[removedPath]) return prev; + const next = { ...prev }; + delete next[removedPath]; + return next; + }); + } + }, [setInputValue, mentionNodeTypes]); + // Effort selector state — guard against undefined when model not found in current provider's list const currentModelMeta = currentModelOption as (typeof currentModelOption & { supportsEffort?: boolean; supportedEffortLevels?: string[] }) | undefined; const showEffortSelector = currentModelMeta?.supportsEffort === true; @@ -424,8 +754,15 @@ export function MessageInput({ > {/* Bridge: listens for file tree "+" button events */} - {/* Command badges (multi-skill stacks; other kinds are singletons) */} - + {/* Unified command + mention badges row */} + {/* CLI badge */} {cliBadge && ( diff --git a/src/components/chat/MessageInputParts.tsx b/src/components/chat/MessageInputParts.tsx index 89845326..14dd95c5 100644 --- a/src/components/chat/MessageInputParts.tsx +++ b/src/components/chat/MessageInputParts.tsx @@ -1,7 +1,7 @@ 'use client'; import { useEffect, useCallback } from 'react'; -import { ArrowUp, Plus, X, Stop, Terminal } from '@/components/ui/icon'; +import { ArrowUp, Plus, X, Stop, Terminal, Brain, NotePencil, Lightning, File as FileIcon, Folder } from '@/components/ui/icon'; import { Button } from '@/components/ui/button'; import { useTranslation } from '@/hooks/useTranslation'; import { @@ -11,6 +11,7 @@ import { } from '@/components/ai-elements/prompt-input'; import type { ChatStatus } from 'ai'; import { isSubmitEnabled } from '@/lib/message-input-logic'; +import type { MentionRef, CommandBadge as CommandBadgeType } from '@/types'; /** * Submit button that's aware of file attachments. Must be rendered inside PromptInput. @@ -183,15 +184,22 @@ export function FileAttachmentsCapsules() { * Used by CommandBadgeList for both single- and multi-badge display. */ export function CommandBadge({ - command, + badge, onRemove, }: { - command: string; + badge: CommandBadgeType; onRemove: () => void; }) { + const icon = badge.kind === 'agent_skill' + ? + : badge.kind === 'codepilot_command' + ? + : ; + return ( - {command} + {icon} + {badge.command} + + ); +} + +export function MentionBadgeList({ + mentions, + onRemove, +}: { + mentions: MentionRef[]; + onRemove: (mention: MentionRef) => void; +}) { + if (mentions.length === 0) return null; + return ( +
+ {mentions.map((m) => ( + + ))} +
+ ); +} + +/** + * Unified row for command badges and @mention badges so they appear in one line-flow. + */ +export function ComposerBadgeRow({ + badges, + mentions, + badgeOrder, + mentionOrder, + onRemoveBadge, + onRemoveMention, +}: { + badges: ReadonlyArray; + mentions: MentionRef[]; + badgeOrder: Record; + mentionOrder: Record; + onRemoveBadge: (command: string) => void; + onRemoveMention: (mention: MentionRef) => void; +}) { + if (badges.length === 0 && mentions.length === 0) return null; + + const mixed = [ + ...badges.map((b, idx) => ({ + kind: 'badge' as const, + order: badgeOrder[b.command] ?? 100000 + idx, + key: `badge-${b.command}`, + badge: b, + })), + ...mentions.map((m, idx) => ({ + kind: 'mention' as const, + order: mentionOrder[m.path] ?? (m.sourceRange?.start ?? 200000 + idx), + key: `mention-${m.path}-${m.nodeType}-${m.sourceRange?.start ?? idx}`, + mention: m, + })), + ].sort((a, b) => a.order - b.order); + + return ( +
+ {mixed.map((item) => + item.kind === 'badge' + ? onRemoveBadge(item.badge.command)} /> + : + )} +
+ ); +} diff --git a/src/components/chat/SlashCommandPopover.tsx b/src/components/chat/SlashCommandPopover.tsx index 560583b7..d5aa11c8 100644 --- a/src/components/chat/SlashCommandPopover.tsx +++ b/src/components/chat/SlashCommandPopover.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback } from 'react'; -import { At, Terminal, NotePencil, Brain, GlobeSimple, Lightning } from '@/components/ui/icon'; +import { Terminal, NotePencil, Brain, GlobeSimple, Lightning, Folder, File } from '@/components/ui/icon'; import { useTranslation } from '@/hooks/useTranslation'; import type { TranslationKey } from '@/i18n'; import type { PopoverItem, PopoverMode } from '@/types'; @@ -101,7 +101,9 @@ export function SlashCommandPopover({ onMouseEnter={() => onSetSelectedIndex(idx)} > {popoverMode === 'file' ? ( - + item.nodeType === 'directory' + ? + : ) : item.builtIn && item.icon ? ( (() => { const ItemIcon = item.icon; return ; })() ) : item.kind === 'agent_skill' ? ( @@ -113,7 +115,7 @@ export function SlashCommandPopover({ ) : ( )} - {item.label} + {item.display || item.label} {(item.descriptionKey || item.description) && ( {item.descriptionKey ? t(item.descriptionKey) : item.description} @@ -141,7 +143,7 @@ export function SlashCommandPopover({ onKeyDown={handleSearchKeyDown} /> ) : ( -
+
Files
)} diff --git a/src/hooks/useSlashCommands.ts b/src/hooks/useSlashCommands.ts index 2b7a84f8..1c281c07 100644 --- a/src/hooks/useSlashCommands.ts +++ b/src/hooks/useSlashCommands.ts @@ -33,6 +33,7 @@ export function useSlashCommands(opts: { closePopover: () => void; onCommand?: (command: string) => void; addBadge: (badge: { command: string; label: string; description: string; kind: SkillKind; installedSource?: "agents" | "claude" }) => void; + onMentionInserted?: (mention: { path: string; nodeType: 'file' | 'directory'; display: string }) => void; /** When true, block immediate commands and badge selection from popover */ isStreaming?: boolean; }): UseSlashCommandsReturn { @@ -54,6 +55,7 @@ export function useSlashCommands(opts: { closePopover, onCommand, addBadge, + onMentionInserted, isStreaming, } = opts; @@ -67,25 +69,24 @@ export function useSlashCommands(opts: { const fetchFiles = useCallback(async (filter: string) => { try { const params = new URLSearchParams(); - if (sessionId) params.set('session_id', sessionId); + if (sessionId) params.set('sessionId', sessionId); + if (!sessionId && workingDirectory) params.set('workingDirectory', workingDirectory); if (filter) params.set('q', filter); - const res = await fetch(`/api/files?${params.toString()}`); + params.set('limit', '50'); + const res = await fetch(`/api/files/suggest?${params.toString()}`); if (!res.ok) return []; const data = await res.json(); - const tree = data.tree || []; - const items: PopoverItem[] = []; - function flattenTree(nodes: Array<{ name: string; path: string; type: string; children?: unknown[] }>) { - for (const node of nodes) { - items.push({ label: node.name, value: node.path }); - if (node.children) flattenTree(node.children as typeof nodes); - } - } - flattenTree(tree); - return items.slice(0, 20); + const items = (data.items || []) as Array<{ path: string; display?: string; type?: 'file' | 'directory'; nodeType?: 'file' | 'directory' }>; + return items.map((item) => ({ + label: item.display || item.path, + value: item.path, + display: item.display || item.path, + nodeType: item.type || item.nodeType || 'file', + })); } catch { return []; } - }, [sessionId]); + }, [sessionId, workingDirectory]); // Fetch skills for / command (built-in + API) const fetchSkills = useCallback(async () => { @@ -209,11 +210,16 @@ export function useSlashCommands(opts: { case 'insert_file_mention': setInputValue(result.newInputValue!); + onMentionInserted?.({ + path: item.value, + nodeType: item.nodeType || 'file', + display: item.display || item.value, + }); closePopover(); setTimeout(() => textareaRef.current?.focus(), 0); return; } - }, [triggerPos, popoverMode, closePopover, onCommand, inputValue, popoverFilter, textareaRef, setInputValue, addBadge, isStreaming]); + }, [triggerPos, popoverMode, closePopover, onCommand, inputValue, popoverFilter, textareaRef, setInputValue, addBadge, onMentionInserted, isStreaming]); // Handle input changes to detect @ and / const handleInputChange = useCallback(async (val: string) => { diff --git a/src/lib/message-input-logic.ts b/src/lib/message-input-logic.ts index 76a50930..5b2e4d6f 100644 --- a/src/lib/message-input-logic.ts +++ b/src/lib/message-input-logic.ts @@ -6,7 +6,7 @@ */ import { BUILT_IN_COMMANDS, COMMAND_PROMPTS } from '@/lib/constants/commands'; -import type { PopoverItem, PopoverMode, CommandBadge, CliBadge } from '@/types'; +import type { PopoverItem, PopoverMode, CommandBadge, CliBadge, MentionNodeType, MentionRef } from '@/types'; // ─── Result types ──────────────────────────────────────────────── @@ -316,3 +316,45 @@ export function buildCliAppend(cliBadge: CliBadge | null): string | undefined { if (!cliBadge) return undefined; return `The user wants to use the installed CLI tool "${cliBadge.name}" if appropriate for this task. Prefer using "${cliBadge.name}" when suitable.`; } + +/** + * Parse @mentions from raw input text and return structured mention refs. + * Mentions keep source ranges so the caller can reconcile edits/deletions. + */ +export function parseMentionRefs( + input: string, + nodeTypeLookup?: Record, +): MentionRef[] { + const refs: MentionRef[] = []; + if (!input) return refs; + + const mentionRegex = /(^|\s)@([^\s@]+)/g; + for (const match of input.matchAll(mentionRegex)) { + const rawPath = (match[2] || '').replace(/[.,!?;:)\]}]+$/, ''); + if (!rawPath) continue; + const full = match[0] || ''; + const start = input.indexOf(full, match.index ?? 0) + full.lastIndexOf('@'); + const end = start + rawPath.length + 1; + refs.push({ + path: rawPath, + nodeType: nodeTypeLookup?.[rawPath] || 'file', + display: rawPath, + sourceRange: { start, end }, + }); + } + return refs; +} + +/** + * Dedupe mentions by path (first mention wins). + */ +export function dedupeMentionsByPath(mentions: MentionRef[]): MentionRef[] { + const seen = new Set(); + const out: MentionRef[] = []; + for (const mention of mentions) { + if (seen.has(mention.path)) continue; + seen.add(mention.path); + out.push(mention); + } + return out; +} diff --git a/src/lib/stream-session-manager.ts b/src/lib/stream-session-manager.ts index fdb8d911..bba8d74c 100644 --- a/src/lib/stream-session-manager.ts +++ b/src/lib/stream-session-manager.ts @@ -21,6 +21,7 @@ import type { TokenUsage, PermissionRequestEvent, FileAttachment, + MentionRef, } from '@/types'; // ========================================== @@ -59,6 +60,7 @@ export interface StartStreamParams { model: string; providerId: string; files?: FileAttachment[]; + mentions?: MentionRef[]; systemPromptAppend?: string; pendingImageNotices?: string[]; /** When true, backend skips saving user message and title update (assistant auto-trigger) */ @@ -294,6 +296,7 @@ async function runStream(stream: ActiveStream, params: StartStreamParams): Promi model: params.model, provider_id: params.providerId, ...(params.files && params.files.length > 0 ? { files: params.files } : {}), + ...(params.mentions && params.mentions.length > 0 ? { mentions: params.mentions } : {}), ...(params.systemPromptAppend ? { systemPromptAppend: params.systemPromptAppend } : {}), ...(params.autoTrigger ? { autoTrigger: true } : {}), ...(params.effort ? { effort: params.effort } : {}), diff --git a/src/types/index.ts b/src/types/index.ts index 298b2fc7..da56b59b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -73,10 +73,13 @@ export type IconComponent = ComponentType< SVGAttributes & RefAttributes & { size?: number | string; className?: string } >; +export type MentionNodeType = 'file' | 'directory'; + /** Shared model for popover items (slash commands, file mentions, skills). */ export interface PopoverItem { label: string; value: string; + display?: string; description?: string; descriptionKey?: TranslationKey; builtIn?: boolean; @@ -85,6 +88,7 @@ export interface PopoverItem { source?: 'global' | 'project' | 'plugin' | 'installed' | 'sdk'; kind?: SkillKind; icon?: IconComponent; + nodeType?: MentionNodeType; } /** Which popover is currently active in the command input. */ @@ -314,6 +318,7 @@ export interface SendMessageRequest { model?: string; mode?: string; provider_id?: string; + mentions?: MentionRef[]; } export interface UpdateMCPConfigRequest { @@ -758,6 +763,16 @@ export interface ReferenceImage { localPath?: string; // file path (generated result) } +export interface MentionRef { + path: string; + nodeType: MentionNodeType; + display: string; + sourceRange: { + start: number; + end: number; + }; +} + // ========================================== // File Attachment Types // ==========================================