diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index b034ff0efa..9852c3882c 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -170,6 +170,7 @@ import { type LoadingPhrasesResolver, type MarkdownTableMode, type WebShellTaskInfo, + type WebShellAtProvider, } from './customization'; import type { CommandDisplayCategoryOrder } from './utils/commandDisplay'; import styles from './App.module.css'; @@ -367,6 +368,8 @@ export interface WebShellProps { hiddenSlashCommands?: string[]; /** Slash command category order. Defaults to custom, skill, system. */ slashCommandCategoryOrder?: CommandDisplayCategoryOrder; + /** Additional @ mention categories shown alongside built-in files/extensions. */ + atProviders?: readonly WebShellAtProvider[]; /** Custom renderer for the tool-card header content after the status icon and tool name. */ renderToolHeaderExtra?: ToolHeaderExtraRenderer; /** Custom renderer for the welcome header. Receives version, cwd, model, and mode. */ @@ -758,6 +761,7 @@ export function App({ onBugReport, hiddenSlashCommands, slashCommandCategoryOrder, + atProviders, renderToolHeaderExtra, renderWelcomeHeader, renderWelcomeFooter, @@ -3971,6 +3975,7 @@ export function App({ commands={commands} skills={loadedSkills} slashCommandCategoryOrder={slashCommandCategoryOrder} + atProviders={atProviders} queuedMessages={queuedTexts} onFocusFooter={handleFocusTaskPill} onPopQueuedMessages={editLastQueuedPrompt} diff --git a/packages/web-shell/client/completions/atCompletion.test.ts b/packages/web-shell/client/completions/atCompletion.test.ts deleted file mode 100644 index 6b411fe564..0000000000 --- a/packages/web-shell/client/completions/atCompletion.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { CompletionContext } from '@codemirror/autocomplete'; -import { EditorState } from '@codemirror/state'; -import { - atCompletionSource, - type ExtensionCompletionEntry, - type GlobFn, - type McpServerCompletionEntry, -} from './atCompletion'; - -const extensions: ExtensionCompletionEntry[] = [ - { - name: 'browser', - displayName: 'Browser', - description: 'Browser automation', - isActive: true, - }, - { - name: 'review-tools', - displayName: 'Review Tools', - description: 'Review code', - isActive: true, - }, - { - name: 'inactive', - displayName: 'Inactive', - isActive: false, - }, -]; - -const mcpServers: McpServerCompletionEntry[] = [ - { - name: 'dataworks', - description: 'DataWorks MCP', - }, - { - name: 'clickhouse', - description: 'ClickHouse MCP', - }, -]; - -function context(doc: string): CompletionContext { - return new CompletionContext(EditorState.create({ doc }), doc.length, true); -} - -describe('atCompletionSource', () => { - it('shows active extensions and files for bare @', async () => { - const glob = vi.fn().mockResolvedValue({ - matches: ['README.md', 'src/index.ts'], - }); - - const result = await atCompletionSource( - context('@'), - () => glob, - () => async () => ({ extensions }), - () => async () => ({ servers: mcpServers }), - ); - - expect(result?.from).toBe(0); - expect(result?.options.map((option) => option.label)).toEqual([ - 'browser', - 'review-tools', - 'README.md', - 'src/index.ts', - 'clickhouse', - 'dataworks', - ]); - expect(glob).toHaveBeenCalledWith('**/*', { maxResults: 50 }); - }); - - it('filters extensions, MCP servers, and files by partial input', async () => { - const glob = vi.fn().mockResolvedValue({ - matches: ['browser-test.ts'], - }); - - const result = await atCompletionSource( - context('@bro'), - () => glob, - () => async () => ({ extensions }), - () => async () => ({ - servers: [ - ...mcpServers, - { name: 'browser-mcp', description: 'Browser MCP' }, - ], - }), - ); - - expect(result?.options.map((option) => option.label)).toEqual([ - 'browser', - 'browser-test.ts', - 'browser-mcp', - ]); - expect(glob).toHaveBeenCalledWith('bro*', { maxResults: 50 }); - }); - - it('shows only extensions for @ext: completion', async () => { - const glob = vi.fn().mockResolvedValue({ - matches: ['ext:file.ts'], - }); - - const result = await atCompletionSource( - context('@ext:rev'), - () => glob, - () => async () => ({ extensions }), - ); - - expect(result?.options.map((option) => option.label)).toEqual([ - 'review-tools', - ]); - expect(result?.options[0]?.apply).toBe('@ext:review-tools '); - expect(glob).not.toHaveBeenCalled(); - }); - - it('returns file completions when extension status fails', async () => { - const glob = vi.fn().mockResolvedValue({ - matches: ['README.md'], - }); - - const result = await atCompletionSource( - context('@'), - () => glob, - () => async () => { - throw new Error('boom'); - }, - ); - - expect(result?.options.map((option) => option.label)).toEqual([ - 'README.md', - ]); - }); -}); diff --git a/packages/web-shell/client/completions/atCompletion.ts b/packages/web-shell/client/completions/atCompletion.ts deleted file mode 100644 index 7060acd497..0000000000 --- a/packages/web-shell/client/completions/atCompletion.ts +++ /dev/null @@ -1,253 +0,0 @@ -import type { - Completion, - CompletionContext, - CompletionResult, - CompletionSection, -} from '@codemirror/autocomplete'; - -export type GlobFn = ( - pattern: string, - opts?: { maxResults?: number }, -) => Promise<{ matches: string[] }>; - -export interface ExtensionCompletionEntry { - name: string; - displayName?: string; - description?: string; - isActive: boolean; -} - -export interface McpServerCompletionEntry { - name: string; - description?: string; -} - -export interface AtReferenceCompletion extends Completion { - atReferenceKind?: 'extension' | 'mcp' | 'file'; - atReferenceLabel?: string; - atReferenceValue?: string; -} - -export type LoadExtensionsFn = () => Promise<{ - extensions: ExtensionCompletionEntry[]; -}>; - -export type LoadMcpServersFn = () => Promise<{ - servers: McpServerCompletionEntry[]; -}>; - -export interface AtCompletionSectionLabels { - extensions: string; - mcpServers: string; - files: string; -} - -function createSection(name: string, rank: number): CompletionSection { - return { name, rank }; -} - -export function createAtCompletionSource( - getGlob: () => GlobFn | undefined, - getLoadExtensions: () => LoadExtensionsFn | undefined = () => undefined, - getLoadMcpServers: () => LoadMcpServersFn | undefined = () => undefined, - sectionLabels: AtCompletionSectionLabels = { - extensions: 'Extensions', - mcpServers: 'MCP Servers', - files: 'Files', - }, -): ( - context: CompletionContext, -) => CompletionResult | null | Promise { - return (context) => - atCompletionSource( - context, - getGlob, - getLoadExtensions, - getLoadMcpServers, - sectionLabels, - ); -} - -export function atCompletionSource( - context: CompletionContext, - getGlob: () => GlobFn | undefined, - getLoadExtensions: () => LoadExtensionsFn | undefined = () => undefined, - getLoadMcpServers: () => LoadMcpServersFn | undefined = () => undefined, - sectionLabels: AtCompletionSectionLabels = { - extensions: 'Extensions', - mcpServers: 'MCP Servers', - files: 'Files', - }, -): CompletionResult | null | Promise { - const line = context.state.doc.lineAt(context.pos); - const textBefore = line.text.slice(0, context.pos - line.from); - - const match = textBefore.match(/@([\w./:-]*)$/); - if (!match) return null; - - const prefix = match[1]; - const atPos = context.pos - match[0].length; - const extensionOnly = prefix.startsWith('ext:'); - const mcpOnly = prefix.startsWith('mcp:'); - const extensionQuery = extensionOnly ? prefix.slice('ext:'.length) : prefix; - const mcpQuery = mcpOnly ? prefix.slice('mcp:'.length) : prefix; - const glob = extensionOnly || mcpOnly ? undefined : getGlob(); - const loadExtensions = getLoadExtensions(); - const loadMcpServers = getLoadMcpServers(); - - return Promise.all([ - fetchExtensions(extensionQuery, loadExtensions), - fetchMcpServers(mcpQuery, loadMcpServers), - glob ? fetchFiles(prefix, glob) : Promise.resolve([]), - ]).then(([extensions, mcpServers, files]) => { - if ( - extensions.length === 0 && - mcpServers.length === 0 && - files.length === 0 - ) - return null; - const sections = { - extensions: createSection(sectionLabels.extensions, 0), - files: createSection(sectionLabels.files, 1), - mcpServers: createSection(sectionLabels.mcpServers, 2), - }; - - return { - from: atPos, - options: [ - ...extensions.map( - (ext) => - ({ - label: ext.name, - apply: `@ext:${ext.name} `, - detail: extensionDetail(ext), - type: 'keyword', - section: sections.extensions, - boost: 20, - atReferenceKind: 'extension', - atReferenceValue: ext.name, - }) satisfies AtReferenceCompletion, - ), - ...files.map( - (f) => - ({ - label: f, - apply: `@${f} `, - type: 'file', - section: sections.files, - atReferenceKind: 'file', - atReferenceValue: f, - }) satisfies AtReferenceCompletion, - ), - ...mcpServers.map( - (server) => - ({ - label: server.name, - apply: `@mcp:${server.name} `, - detail: server.description, - type: 'keyword', - section: sections.mcpServers, - boost: 18, - atReferenceKind: 'mcp', - atReferenceValue: server.name, - }) satisfies AtReferenceCompletion, - ), - ], - filter: false, - }; - }); -} - -async function fetchFiles(prefix: string, glob: GlobFn): Promise { - try { - const pattern = prefix ? `${prefix}*` : '**/*'; - const result = await glob(pattern, { maxResults: 50 }); - return result.matches.filter((file) => file !== '.'); - } catch { - return []; - } -} - -async function fetchMcpServers( - query: string, - loadMcpServers: LoadMcpServersFn | undefined, -): Promise { - if (!loadMcpServers) return []; - try { - const status = await loadMcpServers(); - const lowerQuery = query.toLowerCase(); - return status.servers - .map((server) => ({ - ...server, - description: sanitizeDisplayText(server.description ?? '') ?? undefined, - })) - .filter((server) => server.name.toLowerCase().includes(lowerQuery)) - .sort((a, b) => { - const aLabel = a.name.toLowerCase(); - const bLabel = b.name.toLowerCase(); - const aPrefix = aLabel.startsWith(lowerQuery) ? 0 : 1; - const bPrefix = bLabel.startsWith(lowerQuery) ? 0 : 1; - if (aPrefix !== bPrefix) return aPrefix - bPrefix; - return aLabel.localeCompare(bLabel); - }) - .slice(0, 50); - } catch { - return []; - } -} - -async function fetchExtensions( - query: string, - loadExtensions: LoadExtensionsFn | undefined, -): Promise { - if (!loadExtensions) return []; - try { - const status = await loadExtensions(); - const lowerQuery = query.toLowerCase(); - return status.extensions - .filter((ext) => ext.isActive) - .map((ext) => ({ - ...ext, - displayName: sanitizeDisplayText(ext.displayName ?? '') ?? undefined, - description: sanitizeDisplayText(ext.description ?? '') ?? undefined, - })) - .filter((ext) => { - const name = ext.name.toLowerCase(); - const display = ext.displayName?.toLowerCase() ?? ''; - return name.includes(lowerQuery) || display.includes(lowerQuery); - }) - .sort((a, b) => { - const aLabel = (a.displayName || a.name).toLowerCase(); - const bLabel = (b.displayName || b.name).toLowerCase(); - const aPrefix = aLabel.startsWith(lowerQuery) ? 0 : 1; - const bPrefix = bLabel.startsWith(lowerQuery) ? 0 : 1; - if (aPrefix !== bPrefix) return aPrefix - bPrefix; - return aLabel.localeCompare(bLabel); - }) - .slice(0, 50); - } catch { - return []; - } -} - -function extensionDetail(ext: ExtensionCompletionEntry): string | undefined { - const display = - ext.displayName && ext.displayName !== ext.name - ? ext.displayName - : undefined; - if (display && ext.description) return `${display} - ${ext.description}`; - return display ?? ext.description; -} - -const ESC = String.fromCharCode(27); -const ANSI_RE = new RegExp(`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`, 'g'); -const BIDI_CONTROL_RE = /[‎‏؜⁦⁧⁨⁩‪‫‬‭‮]/g; - -function sanitizeDisplayText(raw: string): string | null { - const stripped = raw - .replace(ANSI_RE, '') - .replace(BIDI_CONTROL_RE, '') - .replace(/\s+/g, ' ') - .trim(); - return stripped.length > 0 ? stripped : null; -} diff --git a/packages/web-shell/client/components/AtMentionPanel.test.tsx b/packages/web-shell/client/components/AtMentionPanel.test.tsx new file mode 100644 index 0000000000..692f61a9de --- /dev/null +++ b/packages/web-shell/client/components/AtMentionPanel.test.tsx @@ -0,0 +1,256 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { AtMentionPanel } from './AtMentionPanel'; +import type { AtMentionMenuState } from '../hooks/useAtMentionMenu'; +import { I18nProvider } from '../i18n'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +class ResizeObserverMock { + observe = vi.fn(); + disconnect = vi.fn(); +} + +globalThis.ResizeObserver = + ResizeObserverMock as unknown as typeof ResizeObserver; + +let container: HTMLDivElement | null = null; +let anchor: HTMLDivElement | null = null; +let root: Root | null = null; + +function categoriesMenu(): AtMentionMenuState { + return { + from: 0, + to: 1, + query: '', + level: 'categories', + selectedIndex: 0, + providers: [ + { + id: 'files', + label: 'Files', + description: 'Reference workspace files', + }, + ], + items: [], + loading: false, + }; +} + +function itemsMenu(): AtMentionMenuState { + return { + ...categoriesMenu(), + level: 'items', + selectedProviderId: 'files', + items: [ + { + id: 'readme', + label: 'README.md', + insertText: '@README.md ', + }, + ], + }; +} + +function mount(menu: AtMentionMenuState, handlers = {}) { + container = document.createElement('div'); + anchor = document.createElement('div'); + document.body.append(container, anchor); + anchor.getBoundingClientRect = vi.fn(() => ({ + x: 20, + y: 100, + top: 100, + right: 420, + bottom: 140, + left: 20, + width: 400, + height: 40, + toJSON: () => ({}), + })); + root = createRoot(container); + act(() => { + root!.render( + + + , + ); + }); +} + +afterEach(() => { + act(() => root?.unmount()); + container?.remove(); + anchor?.remove(); + document.body + .querySelectorAll('[role="listbox"], [role="region"]') + .forEach((node) => node.remove()); + container = null; + anchor = null; + root = null; +}); + +describe('AtMentionPanel', () => { + it('renders provider categories in a listbox', () => { + mount(categoriesMenu()); + + expect( + document.body + .querySelector('[role="region"]') + ?.getAttribute('aria-label'), + ).toBe('Reference menu'); + expect( + document.body + .querySelector('[role="listbox"]') + ?.getAttribute('aria-label'), + ).toBe('Reference menu'); + expect(document.body.textContent).toContain('Files'); + expect(document.body.textContent).toContain('Reference workspace files'); + }); + + it('dispatches item-search keyboard actions', () => { + const onBack = vi.fn(); + const onAccept = vi.fn(); + const onSelect = vi.fn(); + mount(itemsMenu(), { onBack, onAccept, onSelect }); + + const input = document.body.querySelector('input')!; + expect(input.getAttribute('aria-label')).toBe('Search'); + expect(input.getAttribute('aria-controls')).toBe('at-mention-listbox'); + act(() => { + input.focus(); + }); + expect(input.getAttribute('aria-activedescendant')).toBe( + 'at-mention-option-0', + ); + act(() => { + input.blur(); + }); + expect(input.getAttribute('aria-activedescendant')).toBe( + 'at-mention-option-0', + ); + expect(document.getElementById('at-mention-option-0')).not.toBeNull(); + act(() => { + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }), + ); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + input.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), + ); + }); + + expect(onAccept).toHaveBeenCalledTimes(2); + expect(onBack).toHaveBeenCalledOnce(); + expect(onSelect).toHaveBeenCalledWith(0); + }); + + it('dispatches search input changes', () => { + const onSearch = vi.fn(); + mount(itemsMenu(), { onSearch }); + + const input = document.body.querySelector('input')!; + act(() => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input, 'read'); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + + expect(onSearch).toHaveBeenCalledWith('read'); + }); + + it('focuses search when explicitly requested', () => { + vi.useFakeTimers(); + mount({ ...itemsMenu(), inputMode: 'search' }); + + const input = document.body.querySelector('input')!; + act(() => { + vi.runOnlyPendingTimers(); + }); + + expect(document.activeElement).toBe(input); + vi.useRealTimers(); + }); + + it('does not focus search when reopened from an inserted reference', () => { + vi.useFakeTimers(); + mount({ ...itemsMenu(), inputMode: 'context' }); + + const input = document.body.querySelector('input')!; + act(() => { + vi.runOnlyPendingTimers(); + }); + + expect(document.activeElement).not.toBe(input); + vi.useRealTimers(); + }); + + it('lets IME composition keys pass through the item search input', () => { + const onBack = vi.fn(); + const onAccept = vi.fn(); + const onSelect = vi.fn(); + mount(itemsMenu(), { onBack, onAccept, onSelect }); + + const input = document.body.querySelector('input')!; + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + }); + Object.defineProperty(enterEvent, 'isComposing', { value: true }); + const arrowEvent = new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + }); + Object.defineProperty(arrowEvent, 'keyCode', { value: 229 }); + act(() => { + input.dispatchEvent(enterEvent); + input.dispatchEvent(arrowEvent); + }); + + expect(onAccept).not.toHaveBeenCalled(); + expect(onBack).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('activates panel buttons from click events', () => { + const onBack = vi.fn(); + const onAccept = vi.fn(); + mount(itemsMenu(), { onBack, onAccept }); + + const buttons = document.body.querySelectorAll('button'); + act(() => { + buttons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(onBack).toHaveBeenCalledOnce(); + expect(onAccept).toHaveBeenCalledWith(0); + }); + + it('shows loading state', () => { + mount({ ...itemsMenu(), items: [], loading: true }); + expect(document.body.textContent).toContain('Loading...'); + }); + + it('shows empty state', () => { + mount({ ...itemsMenu(), items: [], loading: false }); + expect(document.body.textContent).toContain('No results'); + }); +}); diff --git a/packages/web-shell/client/components/AtMentionPanel.tsx b/packages/web-shell/client/components/AtMentionPanel.tsx new file mode 100644 index 0000000000..7b10bd34b0 --- /dev/null +++ b/packages/web-shell/client/components/AtMentionPanel.tsx @@ -0,0 +1,338 @@ +import { + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type RefObject, +} from 'react'; +import { createPortal } from 'react-dom'; +import { useI18n } from '../i18n'; +import { + FILE_PROVIDER_ID, + sanitizeDisplayText, + type AtMentionMenuState, +} from '../hooks/useAtMentionMenu'; +import styles from './ChatEditor.module.css'; + +const AT_PANEL_THEME_VARS = [ + '--chat-editor-accent-color', + '--chat-editor-text-primary', + '--accent', + '--background', + '--foreground', + '--muted-foreground', + '--chat-editor-border-color', + '--font-sans', +]; + +export function AtMentionPanel({ + menu, + anchorRef, + panelRef, + onSelect, + onAccept, + onBack, + onSearch, +}: { + menu: AtMentionMenuState; + anchorRef: RefObject; + panelRef: RefObject; + onSelect: (index: number) => boolean; + onAccept: (index?: number) => boolean; + onBack: () => boolean; + onSearch: (query: string) => boolean; +}) { + const { t } = useI18n(); + const itemRefs = useRef>([]); + const searchInputRef = useRef(null); + const [anchorRect, setAnchorRect] = useState<{ + left: number; + bottom: number; + width: number; + } | null>(null); + const [themeVars, setThemeVars] = useState({}); + + useEffect(() => { + itemRefs.current[menu.selectedIndex]?.scrollIntoView({ + block: 'nearest', + }); + }, [menu.level, menu.selectedIndex]); + + useEffect(() => { + if ( + menu.level !== 'items' || + menu.inputMode !== 'search' || + document.activeElement === searchInputRef.current + ) { + return; + } + const focusSearch = () => searchInputRef.current?.focus(); + const frame = window.requestAnimationFrame(focusSearch); + const timer = window.setTimeout(focusSearch, 0); + return () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timer); + }; + }, [menu.inputMode, menu.level, menu.selectedProviderId]); + + useLayoutEffect(() => { + const anchor = anchorRef.current; + if (!anchor) return undefined; + + const computedStyle = getComputedStyle(anchor); + setThemeVars( + Object.fromEntries( + AT_PANEL_THEME_VARS.map((name) => [ + name, + computedStyle.getPropertyValue(name), + ]), + ) as CSSProperties, + ); + }, [anchorRef]); + + useLayoutEffect(() => { + const anchor = anchorRef.current; + if (!anchor) return undefined; + let frame: number | null = null; + + const updatePosition = () => { + const rect = anchor.getBoundingClientRect(); + const panelWidth = panelRef.current?.offsetWidth ?? 360; + const next = { + left: Math.max( + 12, + Math.min(rect.left + 16, window.innerWidth - panelWidth - 12), + ), + bottom: window.innerHeight - rect.top + 8, + width: rect.width, + }; + setAnchorRect((prev) => { + if ( + prev && + prev.left === next.left && + prev.bottom === next.bottom && + prev.width === next.width + ) { + return prev; + } + return next; + }); + }; + const scheduleUpdatePosition = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + updatePosition(); + }); + }; + + updatePosition(); + const resizeObserver = new ResizeObserver(scheduleUpdatePosition); + resizeObserver.observe(anchor); + window.addEventListener('resize', scheduleUpdatePosition); + window.addEventListener('scroll', scheduleUpdatePosition, true); + return () => { + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + resizeObserver.disconnect(); + window.removeEventListener('resize', scheduleUpdatePosition); + window.removeEventListener('scroll', scheduleUpdatePosition, true); + }; + }, [anchorRef, panelRef]); + + const rows = + menu.level === 'categories' + ? menu.providers.map((provider) => ({ + id: provider.id, + label: provider.label, + description: provider.description, + trailing: '›', + })) + : menu.items.map((item) => ({ + id: item.id, + label: item.label, + description: + menu.selectedProviderId === FILE_PROVIDER_ID + ? undefined + : (item.description ?? item.detail), + trailing: + item.kind === 'directory' || item.kind === 'mcp-server' ? '›' : '', + })); + + useEffect(() => { + itemRefs.current.length = rows.length; + }, [rows.length]); + + if (!anchorRect) return null; + + const selectedProvider = menu.providers.find( + (provider) => provider.id === menu.selectedProviderId, + ); + const panelTitle = + menu.itemMode === 'mcpResources' && menu.mcpServerName + ? (sanitizeDisplayText(menu.mcpServerName) ?? '[invalid]') + : (selectedProvider?.label ?? ''); + const listboxLabel = + menu.level === 'items' + ? (selectedProvider?.label ?? t('at.menu')) + : t('at.menu'); + const listboxId = 'at-mention-listbox'; + const activeOptionId = + menu.selectedIndex >= 0 && menu.selectedIndex < rows.length + ? `at-mention-option-${menu.selectedIndex}` + : undefined; + + return createPortal( +
+
{ + if (event.target instanceof HTMLInputElement) return; + event.preventDefault(); + }} + > + {menu.level === 'items' && ( +
+
+ + {panelTitle} +
+ event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + onChange={(event) => onSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.nativeEvent.isComposing || + event.nativeEvent.keyCode === 229 + ) { + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onBack(); + } else if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + onAccept(); + } else if ( + event.key === 'Tab' && + !event.shiftKey && + !event.altKey && + !event.ctrlKey && + !event.metaKey + ) { + event.preventDefault(); + event.stopPropagation(); + onAccept(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + if (rows.length === 0) return; + onSelect(Math.max(0, menu.selectedIndex - 1)); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + if (rows.length === 0) return; + onSelect(Math.min(rows.length - 1, menu.selectedIndex + 1)); + } + }} + /> +
+ )} +
0 ? 'listbox' : undefined} + aria-label={rows.length > 0 ? listboxLabel : undefined} + > + {menu.loading && rows.length === 0 ? ( +
+ {t('common.loading')} +
+ ) : rows.length === 0 ? ( +
+ {t('common.noResults')} +
+ ) : ( + rows.map((row, index) => ( + + )) + )} +
+
+
, + document.body, + ); +} diff --git a/packages/web-shell/client/components/ChatEditor.module.css b/packages/web-shell/client/components/ChatEditor.module.css index baf86ad16b..ccfbb66f8f 100644 --- a/packages/web-shell/client/components/ChatEditor.module.css +++ b/packages/web-shell/client/components/ChatEditor.module.css @@ -178,6 +178,10 @@ display: contents; } +.atPortalLayer { + display: contents; +} + .slashPanel { position: fixed; z-index: var(--web-shell-popover-z-index, 1000); @@ -229,6 +233,7 @@ overflow-x: hidden; overflow-y: auto; border-radius: 6px; + scroll-padding-bottom: 12px; scrollbar-color: var(--chat-editor-border-color) transparent; scrollbar-width: thin; } @@ -370,6 +375,190 @@ white-space: nowrap; } +.atPanel { + position: fixed; + z-index: var(--web-shell-popover-z-index, 1000); + --at-anchor-width: 620px; + display: flex; + width: min(360px, calc(var(--at-anchor-width) - 32px), calc(100vw - 24px)); + max-height: min(300px, 42vh); + box-sizing: border-box; + flex-direction: column; + padding: 6px; + overflow: hidden; + border: 1px solid var(--chat-editor-border-color); + border-radius: 8px; + background: var(--background); + box-shadow: + 0 2px 4px -2px rgba(0, 0, 0, 0.1), + 0 4px 6px -1px rgba(0, 0, 0, 0.1); + color: var(--chat-editor-text-primary); + cursor: default; + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 13px; + line-height: 1.35; +} + +.atPanelHeaderWrap { + flex: 0 0 auto; + padding: 3px 4px 7px; + margin-bottom: 4px; + border-bottom: 1px solid var(--chat-editor-border-color); +} + +.atPanelHeader { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.atBackButton { + display: inline-flex; + width: 22px; + height: 22px; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + font: inherit; + font-size: 18px; + line-height: 1; +} + +.atBackButton:hover { + background: var(--accent); + color: var(--foreground); +} + +.atPanelTitle { + min-width: 0; + overflow: hidden; + color: var(--foreground); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atSearchInput { + width: 100%; + min-width: 0; + box-sizing: border-box; + padding: 5px 7px; + border: 1px solid var(--chat-editor-border-color); + border-radius: 6px; + outline: none; + background: transparent; + color: var(--foreground); + font: inherit; + font-size: 12px; + line-height: 18px; +} + +.atSearchInput::placeholder { + color: var(--muted-foreground); +} + +.atList { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; + overflow-x: hidden; + overflow-y: auto; + border-radius: 6px; + scroll-padding-bottom: 6px; + scrollbar-color: var(--chat-editor-border-color) transparent; + scrollbar-width: thin; +} + +.atList::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +.atList::-webkit-scrollbar-track { + background: transparent; +} + +.atList::-webkit-scrollbar-thumb { + border-radius: 3px; + background: var(--chat-editor-border-color); +} + +.atItem { + display: grid; + width: 100%; + min-width: 0; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + 'label trailing' + 'description trailing'; + column-gap: 8px; + row-gap: 1px; + align-items: center; + padding: 6px 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--foreground); + font: inherit; + text-align: left; + cursor: pointer; +} + +.atItem:hover, +.atItemActive { + background: var(--accent); +} + +.atItemSingleLine { + grid-template-areas: 'label trailing'; + padding-top: 5px; + padding-bottom: 5px; +} + +.atItemLabel { + grid-area: label; + min-width: 0; + overflow: hidden; + color: var(--foreground); + font-size: 13px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atItemDescription { + grid-area: description; + min-width: 0; + overflow: hidden; + color: var(--muted-foreground); + font-size: 12px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.atItemTrailing { + grid-area: trailing; + color: var(--muted-foreground); + font-size: 16px; + line-height: 1; +} + +.atEmpty { + padding: 10px 8px; + color: var(--muted-foreground); + font-size: 12px; +} + @container (max-width: 699px) { .slashPanel { left: 12px; diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index a695f96eaa..71894b4ec6 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -21,6 +21,7 @@ import { useWebShellCustomization, type WebShellComposerInput, type WebShellComposerTag, + type WebShellAtProvider, } from '../customization'; import { useComposerCore, @@ -30,6 +31,7 @@ import { getComposerTagLabel, getComposerTagValue, } from '../hooks/useComposerCore'; +import { AtMentionPanel } from './AtMentionPanel'; import { cssUrlVar } from '../utils/cssUrlVar'; import { getComposerTagIconUrl } from './composerTagIcons'; import { ModeIcon } from './ModeIcon'; @@ -94,6 +96,7 @@ interface ChatEditorProps { sessionName?: string; composerInput?: WebShellComposerInput; composerInputVersion?: number; + atProviders?: readonly WebShellAtProvider[]; } const CHAT_EDITOR_THEME = { @@ -913,6 +916,7 @@ export const ChatEditor = memo( sessionName, composerInput, composerInputVersion, + atProviders, } = props; const core = useComposerCore({ @@ -935,6 +939,7 @@ export const ChatEditor = memo( sessionName, composerInput, composerInputVersion, + atProviders, editorTheme: CHAT_EDITOR_THEME, }); @@ -953,11 +958,16 @@ export const ChatEditor = memo( const [showQuickActions, setShowQuickActions] = useState(isTouchLikeDevice); const containerRef = useRef(null); const slashPanelRef = useRef(null); + const atPanelRef = useRef(null); const modeBtnRef = useRef(null); const modelBtnRef = useRef(null); const [widthToggleFits, setWidthToggleFits] = useState(false); const slashMenu = core.slashMenu; const closeSlashMenu = core.closeSlashMenu; + const atMenu = core.atMenu; + const closeAtMenu = core.closeAtMenu; + const hasSlashMenu = Boolean(slashMenu); + const hasAtMenu = Boolean(atMenu); const editorViewRef = core.viewRef; useEffect(() => { @@ -974,7 +984,7 @@ export const ChatEditor = memo( }, [showQuickActions]); useEffect(() => { - if (!slashMenu) return; + if (!hasSlashMenu && !hasAtMenu) return; const onPointerOutside = (event: Event) => { const target = event.target; const container = containerRef.current; @@ -982,9 +992,11 @@ export const ChatEditor = memo( target instanceof Node && container && !container.contains(target) && - !slashPanelRef.current?.contains(target) + !slashPanelRef.current?.contains(target) && + !atPanelRef.current?.contains(target) ) { closeSlashMenu(); + closeAtMenu(); } }; window.addEventListener('mousedown', onPointerOutside); @@ -993,7 +1005,7 @@ export const ChatEditor = memo( window.removeEventListener('mousedown', onPointerOutside); window.removeEventListener('touchstart', onPointerOutside); }; - }, [slashMenu, closeSlashMenu]); + }, [hasAtMenu, hasSlashMenu, closeAtMenu, closeSlashMenu]); useEffect(() => { const glowRoot = containerRef.current; @@ -1211,6 +1223,7 @@ export const ChatEditor = memo( setModeDropdownOpen(false); setModelDropdownOpen(false); core.closeSlashMenu(); + core.closeAtMenu(); if (action.action.type === 'insert') { core.insertText(action.action.text, { mode: 'replace' }); return; @@ -1416,6 +1429,23 @@ export const ChatEditor = memo( onAccept={core.acceptSlashCompletion} /> )} + {core.atMenu && ( + { + const result = core.backAtCategories(); + if (result === 'categories') { + window.setTimeout(() => core.focus(), 0); + } + return Boolean(result); + }} + onSearch={core.updateAtSearch} + /> + )}
{core.shellMode && (