feat(web-shell): add custom at mention panel (#6242)

* feat(web-shell): add custom at mention panel

* chore(web-shell): remove dev MCP resource server

* test(web-shell): cover at mention accept paths

* fix(web-shell): support keyboard at mention activation

* fix(web-shell): address at mention review feedback

* fix(web-shell): close stale at mention panels

* fix(web-shell): keep reopened at mention query empty

* fix(web-shell): address at mention review feedback

* fix(web-shell): harden at mention panel state

* fix(web-shell): stabilize at mention menu state

* fix(web-shell): cache at mention provider listings

* fix(web-shell): address at mention review follow-ups

* fix(web-shell): address at mention review threads

* fix(web-shell): address at mention review regressions

* fix(web-shell): relax auto at trigger cleanup

* chore: remove unrelated pr diff

* fix(web-shell): address at mention review followups

* fix(web-shell): harden at mention review edges

* test(web-shell): cover at mention disabled guards

* fix(web-shell): escape at mention provider delimiters

* fix(web-shell): escape unsafe at reference characters

* fix(web-shell): preserve escaped at mention context

* fix(web-shell): strip control chars from at mentions

* test(web-shell): cover at mention mcp resource guard

* fix(web-shell): propagate at panel text color

* test(web-shell): cover at mention provider failures

* fix(web-shell): handle escaped mcp resource searches

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
ytahdn 2026-07-05 09:17:35 +08:00 committed by GitHub
parent 015ee42489
commit acfb00e1d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 4661 additions and 489 deletions

View file

@ -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}

View file

@ -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<GlobFn>().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<GlobFn>().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<GlobFn>().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<GlobFn>().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',
]);
});
});

View file

@ -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<CompletionResult | null> {
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<CompletionResult | null> {
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<string[]> {
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<McpServerCompletionEntry[]> {
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<ExtensionCompletionEntry[]> {
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;
}

View file

@ -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(
<I18nProvider language="en">
<AtMentionPanel
menu={menu}
anchorRef={{ current: anchor }}
panelRef={{ current: null }}
onSelect={vi.fn()}
onAccept={vi.fn()}
onBack={vi.fn()}
onSearch={vi.fn()}
{...handlers}
/>
</I18nProvider>,
);
});
}
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');
});
});

View file

@ -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<HTMLElement | null>;
panelRef: RefObject<HTMLDivElement | null>;
onSelect: (index: number) => boolean;
onAccept: (index?: number) => boolean;
onBack: () => boolean;
onSearch: (query: string) => boolean;
}) {
const { t } = useI18n();
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const [anchorRect, setAnchorRect] = useState<{
left: number;
bottom: number;
width: number;
} | null>(null);
const [themeVars, setThemeVars] = useState<CSSProperties>({});
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(
<div className={styles.atPortalLayer} style={themeVars}>
<div
ref={panelRef}
className={styles.atPanel}
data-at-mention-panel="true"
style={
{
...themeVars,
left: anchorRect.left,
bottom: anchorRect.bottom,
'--at-anchor-width': `${anchorRect.width}px`,
} as CSSProperties
}
role="region"
aria-label={listboxLabel}
onMouseDown={(event) => {
if (event.target instanceof HTMLInputElement) return;
event.preventDefault();
}}
>
{menu.level === 'items' && (
<div className={styles.atPanelHeaderWrap}>
<div className={styles.atPanelHeader}>
<button
type="button"
className={styles.atBackButton}
aria-label={t('common.back')}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onBack();
}}
>
</button>
<span className={styles.atPanelTitle}>{panelTitle}</span>
</div>
<input
ref={searchInputRef}
className={styles.atSearchInput}
value={menu.query}
placeholder={t('common.search')}
aria-label={t('common.search')}
aria-controls={listboxId}
aria-activedescendant={activeOptionId}
onMouseDown={(event) => 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));
}
}}
/>
</div>
)}
<div
id={listboxId}
className={styles.atList}
role={rows.length > 0 ? 'listbox' : undefined}
aria-label={rows.length > 0 ? listboxLabel : undefined}
>
{menu.loading && rows.length === 0 ? (
<div className={styles.atEmpty} role="status" aria-live="polite">
{t('common.loading')}
</div>
) : rows.length === 0 ? (
<div className={styles.atEmpty} role="status" aria-live="polite">
{t('common.noResults')}
</div>
) : (
rows.map((row, index) => (
<button
key={row.id}
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
id={`at-mention-option-${index}`}
role="option"
aria-selected={index === menu.selectedIndex}
className={`${styles.atItem} ${
index === menu.selectedIndex ? styles.atItemActive : ''
} ${row.description ? '' : styles.atItemSingleLine}`}
onMouseEnter={() => onSelect(index)}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onAccept(index);
}}
>
<span className={styles.atItemLabel}>{row.label}</span>
{row.description && (
<span className={styles.atItemDescription}>
{row.description}
</span>
)}
{row.trailing && (
<span className={styles.atItemTrailing} aria-hidden="true">
{row.trailing}
</span>
)}
</button>
))
)}
</div>
</div>
</div>,
document.body,
);
}

View file

@ -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;

View file

@ -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<HTMLDivElement>(null);
const slashPanelRef = useRef<HTMLDivElement>(null);
const atPanelRef = useRef<HTMLDivElement>(null);
const modeBtnRef = useRef<HTMLButtonElement>(null);
const modelBtnRef = useRef<HTMLButtonElement>(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 && (
<AtMentionPanel
menu={core.atMenu}
anchorRef={containerRef}
panelRef={atPanelRef}
onSelect={core.selectAtCompletion}
onAccept={core.acceptAtCompletion}
onBack={() => {
const result = core.backAtCategories();
if (result === 'categories') {
window.setTimeout(() => core.focus(), 0);
}
return Boolean(result);
}}
onSearch={core.updateAtSearch}
/>
)}
<div className={styles.editorArea}>
{core.shellMode && (
<span className={styles.shellPrefix} aria-hidden="true">
@ -1454,6 +1484,7 @@ export const ChatEditor = memo(
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
core.closeAtMenu();
setQuickActionsOpen(false);
setModeDropdownOpen((v) => !v);
setModelDropdownOpen(false);
@ -1488,6 +1519,7 @@ export const ChatEditor = memo(
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
core.closeAtMenu();
setQuickActionsOpen(false);
setModelDropdownOpen((v) => !v);
setModeDropdownOpen(false);
@ -1524,6 +1556,7 @@ export const ChatEditor = memo(
onClick={(e) => {
e.stopPropagation();
core.closeSlashMenu();
core.closeAtMenu();
setModeDropdownOpen(false);
setModelDropdownOpen(false);
setQuickActionsOpen((value) => !value);

View file

@ -110,6 +110,25 @@ export interface WebShellComposerInput {
submit?: boolean;
}
export interface WebShellAtItem {
id: string;
label: string;
description?: string;
detail?: string;
insertText?: string;
}
export interface WebShellAtProvider {
id: string;
label: string;
description?: string;
order?: number;
search(params: {
query: string;
signal: AbortSignal;
}): Promise<readonly WebShellAtItem[]>;
}
export interface WebShellComposerApi {
insertText(text: string, options?: WebShellComposerTextOptions): void;
setText(text: string): void;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,6 @@ import {
closeCompletion,
completionStatus,
moveCompletionSelection,
pickedCompletion,
startCompletion,
type Completion,
} from '@codemirror/autocomplete';
@ -45,11 +44,8 @@ import {
DEFAULT_COMMAND_CATEGORY_ORDER,
type CommandDisplayCategoryOrder,
} from '../utils/commandDisplay';
import {
createAtCompletionSource,
type AtReferenceCompletion,
} from '../completions/atCompletion';
import { useInputHistory } from '../hooks/useInputHistory';
import { useAtMentionMenu, type AtMentionMenuState } from './useAtMentionMenu';
import { useI18n } from '../i18n';
import {
inputHighlight,
@ -61,9 +57,9 @@ import type {
WebShellComposerApi,
WebShellComposerInput,
WebShellComposerTag,
WebShellComposerTagKind,
WebShellComposerTagOptions,
WebShellComposerTextOptions,
WebShellAtProvider,
} from '../customization';
// ---- Large paste handling (shared utilities) ----
@ -500,24 +496,6 @@ export function getComposerTagDisplay(tag: WebShellComposerTag): string {
return getComposerTagValue(tag) || getComposerTagLabel(tag) || tag.id;
}
function buildAtReferenceTag(
completion: AtReferenceCompletion,
): WebShellComposerTag | null {
if (!completion.atReferenceKind || !completion.label) return null;
const kind = completion.atReferenceKind as WebShellComposerTagKind;
const serialized =
typeof completion.apply === 'string'
? completion.apply.trim()
: completion.label.trim();
return {
id: serialized,
kind,
label: completion.atReferenceLabel,
value: completion.atReferenceValue,
serialized,
};
}
export function buildComposerPrompt(
text: string,
tags: readonly WebShellComposerTag[],
@ -822,6 +800,7 @@ export interface UseComposerCoreOptions {
sessionName?: string;
composerInput?: WebShellComposerInput;
composerInputVersion?: number;
atProviders?: readonly WebShellAtProvider[];
/** CodeMirror theme extension for the editor view. Each variant provides its own. */
editorTheme: Parameters<typeof EditorView.theme>[0];
}
@ -930,6 +909,13 @@ export interface UseComposerCoreReturn {
closeSlashMenu: () => void;
selectSlashCompletion: (index: number) => boolean;
acceptSlashCompletion: (index?: number) => boolean;
atMenu: AtMentionMenuState | null;
closeAtMenu: () => void;
selectAtCompletion: (index: number) => boolean;
acceptAtCompletion: (index?: number) => boolean;
enterAtCategory: (index?: number) => boolean;
backAtCategories: () => false | 'items' | 'categories';
updateAtSearch: (query: string) => boolean;
}
export function useComposerCore(
@ -955,6 +941,7 @@ export function useComposerCore(
sessionName,
composerInput,
composerInputVersion,
atProviders,
editorTheme,
} = options;
@ -997,6 +984,15 @@ export function useComposerCore(
const [shellMode, setShellMode] = useState(false);
const shellModeRef = useRef(shellMode);
shellModeRef.current = shellMode;
const atMenu = useAtMentionMenu({
viewRef,
disabledRef,
shellModeRef,
workspaceActionsRef,
providers: atProviders,
});
const closeAtMenuState = atMenu.close;
const refreshAtMenuForView = atMenu.refreshForView;
const toggleShellMode = useCallback(() => {
if (followupStateRef.current?.isVisible) {
onDismissFollowupRef.current?.();
@ -1044,6 +1040,42 @@ export function useComposerCore(
setSlashMenuState(next);
}, []);
const clearAutoAtTriggerIfIntact = useCallback(() => {
const trigger = autoTriggerRef.current;
const view = viewRef.current;
if (!trigger || !view) return;
const doc = view.state.doc;
const to = trigger.from + trigger.text.length;
if (
doc.length === to &&
doc.sliceString(trigger.from, to) === trigger.text
) {
view.dispatch({
changes: {
from: trigger.from,
to,
insert: '',
},
});
}
autoTriggerRef.current = null;
}, []);
const closeAtMenu = useCallback(() => {
clearAutoAtTriggerIfIntact();
closeAtMenuState();
}, [clearAutoAtTriggerIfIntact, closeAtMenuState]);
const closeAtMenuIfOpenFn = atMenu.closeIfOpen;
const closeAtMenuIfOpen = useCallback(() => {
const result = closeAtMenuIfOpenFn();
if (!result) return false;
if (result === 'closed') {
clearAutoAtTriggerIfIntact();
}
return true;
}, [clearAutoAtTriggerIfIntact, closeAtMenuIfOpenFn]);
const refreshSlashMenuForView = useCallback(
(view: EditorView | null, preferredIndex?: number) => {
if (!view || disabledRef.current || shellModeRef.current) {
@ -1075,6 +1107,7 @@ export function useComposerCore(
setSlashMenu(null);
return;
}
closeAtMenu();
const currentIndex =
preferredIndex ?? slashMenuRef.current?.selectedIndex ?? 0;
const selectedIndex = Math.max(
@ -1083,7 +1116,7 @@ export function useComposerCore(
);
setSlashMenu({ ...result, selectedIndex });
},
[setSlashMenu],
[closeAtMenu, setSlashMenu],
);
const closeSlashMenu = useCallback(() => {
@ -1211,6 +1244,7 @@ export function useComposerCore(
const view = viewRef.current;
if (!view) return;
closeSlashMenu();
closeAtMenu();
const query = view.state.doc.toString();
searchDraftRef.current = query;
setSearchMode(true);
@ -1222,7 +1256,7 @@ export function useComposerCore(
setSearchActiveIndex(0);
history.resetSearch();
setTimeout(() => searchInputRef.current?.focus(), 0);
}, [closeSlashMenu, getSearchMatches]);
}, [closeAtMenu, closeSlashMenu, getSearchMatches]);
const openHistorySearchRef = useRef(openHistorySearch);
openHistorySearchRef.current = openHistorySearch;
@ -1391,31 +1425,6 @@ export function useComposerCore(
};
submitTextRef.current = submitText;
const completionSources = [
createAtCompletionSource(
() => workspaceActionsRef.current?.globWorkspace,
() => workspaceActionsRef.current?.loadExtensionsStatus,
() =>
workspaceActionsRef.current?.loadMcpStatus
? async () => {
const status =
await workspaceActionsRef.current!.loadMcpStatus();
return {
servers: (status?.servers ?? []).map((server) => ({
name: server.name,
description: server.description,
})),
};
}
: undefined,
{
extensions: t('quickActions.extensions'),
mcpServers: t('mcp.title'),
files: t('editor.hintFiles'),
},
),
];
const insertNewline = (view: EditorView) => {
view.dispatch(view.state.replaceSelection('\n'));
return true;
@ -1492,6 +1501,9 @@ export function useComposerCore(
{
key: 'Enter',
run: (view) => {
if (atMenu.accept()) {
return true;
}
if (slashMenuRef.current) {
return acceptSlashCompletion();
}
@ -1527,6 +1539,9 @@ export function useComposerCore(
{
key: 'Escape',
run: () => {
if (closeAtMenuIfOpen()) {
return true;
}
if (slashMenuRef.current) {
closeSlashMenu();
return true;
@ -1565,6 +1580,7 @@ export function useComposerCore(
// auto-opened menu is closed. (Gate uses historyBrowseActiveRef, not
// the sticky history.isNavigating — see its declaration.)
if (!isBrowsingHistory) {
if (atMenu.moveSelection('up')) return true;
if (moveSlashCompletionSelection('up')) return true;
if (completionStatus(view.state) === 'active') {
return moveCompletionSelection(false)(view);
@ -1572,6 +1588,7 @@ export function useComposerCore(
} else {
closeCompletion(view);
closeSlashMenu();
closeAtMenu();
}
const multilineBoundary = handleMultilineHistoryBoundary(view, 'up');
if (multilineBoundary === 'handled') return true;
@ -1614,6 +1631,7 @@ export function useComposerCore(
// the slash menu and native completion only capture arrows once the
// user is no longer paging through history.
if (!isBrowsingHistory) {
if (atMenu.moveSelection('down')) return true;
if (moveSlashCompletionSelection('down')) return true;
if (completionStatus(view.state) === 'active') {
return moveCompletionSelection(true)(view);
@ -1621,6 +1639,7 @@ export function useComposerCore(
} else {
closeCompletion(view);
closeSlashMenu();
closeAtMenu();
}
const multilineBoundary = handleMultilineHistoryBoundary(
view,
@ -1660,6 +1679,9 @@ export function useComposerCore(
{
key: 'Tab',
run: (view) => {
if (atMenu.accept()) {
return true;
}
if (acceptFollowupIntoEditor(view, 'tab')) {
return true;
}
@ -1746,6 +1768,15 @@ export function useComposerCore(
}
if (update.docChanged || update.selectionSet) {
refreshSlashMenuForView(update.view);
// Match slash command behavior: history-recalled text like "@foo"
// should stay as plain recalled input until the user edits it.
if (historyBrowseActiveRef.current) {
closeAtMenu();
} else {
if (refreshAtMenuForView(update.view)) {
closeSlashMenu();
}
}
}
});
@ -1771,7 +1802,13 @@ export function useComposerCore(
d.length === from + trigger.text.length &&
d.sliceString(from) === trigger.text
) {
view.dispatch({ changes: { from, to: d.length, insert: '' } });
view.dispatch({
changes: {
from,
to: from + trigger.text.length,
insert: '',
},
});
}
}, 0);
}
@ -1790,38 +1827,18 @@ export function useComposerCore(
history(),
keymap.of([...defaultKeymap, ...historyKeymap]),
autocompletion({
override: completionSources,
override: [],
activateOnTyping: true,
icons: false,
optionClass: (completion) => {
const ref = completion as AtReferenceCompletion;
const classes: string[] = [];
if (completion.type === 'file') classes.push('cm-file-completion');
if (ref.atReferenceKind) {
classes.push(`cm-at-ref-completion-${ref.atReferenceKind}`);
}
if (hasCommandHoverInfo(completion)) {
classes.push('cm-command-info-completion');
}
return classes.join(' ');
},
addToOptions: [
{
render: (completion) => {
const ref = completion as AtReferenceCompletion;
if (!ref.atReferenceKind) return null;
const iconUrl = getComposerTagIconUrl(ref.atReferenceKind);
if (!iconUrl) return null;
const icon = document.createElement('span');
icon.className = 'cm-at-ref-completion-icon';
icon.style.setProperty(
'--composer-tag-icon-url',
`url("${iconUrl}")`,
);
return icon;
},
position: 20,
},
{
render: renderCompletionHoverInfo,
position: 90,
@ -1862,29 +1879,6 @@ export function useComposerCore(
triggerCleanupListener,
// Update hasContent state when document changes
EditorView.updateListener.of((update) => {
for (const tr of update.transactions) {
const completion = tr.annotation(pickedCompletion) as
| AtReferenceCompletion
| undefined;
if (!completion) continue;
const tag = buildAtReferenceTag(completion);
if (!tag) continue;
const inserted = serializeComposerTag(tag);
const changes = tr.changes;
if (changes.empty) continue;
let from = -1;
changes.iterChanges((_fA, _tA, fromB, toB, insertedText) => {
if (from !== -1) return;
if (insertedText.toString().includes(inserted)) {
from = fromB;
}
});
if (from === -1) continue;
const to = from + inserted.length;
update.view.dispatch({
effects: addInlineTagEffect.of({ from, to, tag }),
});
}
if (update.docChanged) {
const text = update.state.doc.toString();
const followup = followupStateRef.current;
@ -1920,8 +1914,25 @@ export function useComposerCore(
return false;
}),
EditorView.domEventHandlers({
blur() {
blur(event) {
closeSlashMenu();
if (
event.relatedTarget instanceof Element &&
event.relatedTarget.closest('[data-at-mention-panel="true"]')
) {
return false;
}
window.setTimeout(() => {
const currentView = viewRef.current;
if (currentView?.hasFocus) return;
if (
document.activeElement instanceof Element &&
document.activeElement.closest('[data-at-mention-panel="true"]')
) {
return;
}
closeAtMenu();
}, 0);
return false;
},
paste(event) {
@ -2095,11 +2106,12 @@ export function useComposerCore(
if (!view) return;
if (dialogOpen) {
closeSlashMenu();
closeAtMenu();
view.contentDOM.blur();
} else {
view.focus();
}
}, [dialogOpen, closeSlashMenu]);
}, [closeAtMenu, dialogOpen, closeSlashMenu]);
// Global keydown handler for focus-stealing
useEffect(() => {
@ -2196,7 +2208,8 @@ export function useComposerCore(
window.setTimeout(() => {
const nextView = viewRef.current;
if (nextView && nextView.hasFocus) {
startCompletion(nextView);
closeSlashMenu();
refreshAtMenuForView(nextView);
}
}, 0);
}
@ -2204,7 +2217,14 @@ export function useComposerCore(
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [searchMode, dialogOpen, refreshSlashMenuForView, toggleShellMode]);
}, [
refreshAtMenuForView,
closeSlashMenu,
searchMode,
dialogOpen,
refreshSlashMenuForView,
toggleShellMode,
]);
// ---- Imperative methods ----
@ -2285,12 +2305,13 @@ export function useComposerCore(
window.setTimeout(() => {
const nextView = viewRef.current;
if (nextView && nextView.hasFocus) {
startCompletion(nextView);
closeSlashMenu();
refreshAtMenuForView(nextView);
}
}, 0);
}
},
[refreshSlashMenuForView],
[closeSlashMenu, refreshAtMenuForView, refreshSlashMenuForView],
);
const getText = useCallback(() => {
@ -2760,5 +2781,12 @@ export function useComposerCore(
closeSlashMenu,
selectSlashCompletion,
acceptSlashCompletion,
atMenu: atMenu.state,
closeAtMenu,
selectAtCompletion: atMenu.select,
acceptAtCompletion: atMenu.accept,
enterAtCategory: atMenu.enterCategory,
backAtCategories: atMenu.backToCategories,
updateAtSearch: atMenu.updateSearch,
};
}

View file

@ -177,6 +177,13 @@ const EN: Messages = {
'approval.option.allowAlwaysTool': 'Always allow for this tool',
'assistant.branch': 'Branch',
'assistant.copy': 'Copy',
'at.category.extensions': 'Extensions',
'at.category.extensions.description': 'Reference active extensions',
'at.category.files': 'Files',
'at.category.files.description': 'Reference workspace files',
'at.category.mcpResources': 'MCP resources',
'at.category.mcpResources.description': 'Reference MCP server resources',
'at.menu': 'Reference menu',
'common.back': 'back',
'common.cancel': 'cancel',
'common.close': 'close',
@ -189,6 +196,7 @@ const EN: Messages = {
'common.loading': 'Loading...',
'common.navigate': '↑↓ to navigate',
'common.next': 'next',
'common.noResults': 'No results',
'common.previous': 'previous',
'common.refresh': 'refresh',
'common.search': 'Search',
@ -1536,6 +1544,13 @@ const ZH: Messages = {
'approval.option.allowAlwaysTool': '对此工具始终允许',
'assistant.branch': '分叉',
'assistant.copy': '复制',
'at.category.extensions': '扩展',
'at.category.extensions.description': '引用已启用扩展',
'at.category.files': '文件',
'at.category.files.description': '引用工作区文件',
'at.category.mcpResources': 'MCP 资源',
'at.category.mcpResources.description': '引用 MCP server 资源',
'at.menu': '引用菜单',
'common.back': '返回',
'common.cancel': '取消',
'common.close': '关闭',
@ -1548,6 +1563,7 @@ const ZH: Messages = {
'common.loading': '加载中...',
'common.navigate': '↑↓ 导航',
'common.next': '下一步',
'common.noResults': '无结果',
'common.previous': '上一步',
'common.refresh': '刷新',
'common.search': '搜索',

View file

@ -30,6 +30,8 @@ export type {
WebShellFooterRenderInfo,
FooterRenderer,
LoadingPhrasesResolver,
WebShellAtItem,
WebShellAtProvider,
WebShellCodeBlockRenderInfo,
WebShellTaskInfo,
WebShellAgentTask,