mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
015ee42489
commit
acfb00e1d5
13 changed files with 4661 additions and 489 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
256
packages/web-shell/client/components/AtMentionPanel.test.tsx
Normal file
256
packages/web-shell/client/components/AtMentionPanel.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
338
packages/web-shell/client/components/AtMentionPanel.tsx
Normal file
338
packages/web-shell/client/components/AtMentionPanel.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
2060
packages/web-shell/client/hooks/useAtMentionMenu.test.tsx
Normal file
2060
packages/web-shell/client/hooks/useAtMentionMenu.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
1610
packages/web-shell/client/hooks/useAtMentionMenu.ts
Normal file
1610
packages/web-shell/client/hooks/useAtMentionMenu.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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': '搜索',
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ export type {
|
|||
WebShellFooterRenderInfo,
|
||||
FooterRenderer,
|
||||
LoadingPhrasesResolver,
|
||||
WebShellAtItem,
|
||||
WebShellAtProvider,
|
||||
WebShellCodeBlockRenderInfo,
|
||||
WebShellTaskInfo,
|
||||
WebShellAgentTask,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue