mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-21 06:35:07 +00:00
* 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>
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
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,
|
||
);
|
||
}
|