mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-22 07:04:58 +00:00
feat(web-shell): add context mention customization (#6578)
* feat(web-shell): add context mention customization * fix(web-shell): address context mention review comments * fix(web-shell): harden custom context rendering * fix(web-shell): guard custom tag render fallbacks * fix(web-shell): harden custom context rendering --------- Co-authored-by: 易良 <1204183885@qq.com> Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
37f7d5c69b
commit
24bca9a718
18 changed files with 2246 additions and 193 deletions
|
|
@ -7,17 +7,17 @@
|
|||
|
||||
> **实现状态对照(当前 PR)**:
|
||||
>
|
||||
> | 部分 | 状态 |
|
||||
> | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
|
||||
> | 删除全局 compactMode(context/settings/toggle/i18n key、`mergeCompactToolGroups`) | ✅ 已实现 |
|
||||
> | `fullDetail` 管线(`HistoryItemDisplay`/`ToolGroupMessage`,并入 `forceExpandAll`/`forceShowResult`/思考块 expanded) | ✅ 已实现 |
|
||||
> | `fullDetail` 不被 `ToolGroupMessage` 两个 early return 绕过(纯并行/ memory-only 守 `!fullDetail`) | ✅ 已实现 + 回归测试 |
|
||||
> | `TranscriptView` + alt-screen 接入(Ctrl+O 开关、Esc/q/Ctrl+C 关闭、双段冻结、退出重绘、后台确认自动关闭、消息队列守卫) | ✅ 已实现 |
|
||||
> | 基于 #5661 type-based partition 的 rebase(已合入 main) | ✅ 已实现 |
|
||||
> | `AlternateScreen` 的 `process.stdout.isTTY` guard(§4.2) | ✅ 已实现 + 测试 |
|
||||
> | i18n 旧 compact 文案清理(9 语言)+ KeyboardShortcuts `ctrl+o → view transcript` 文案(§5) | ✅ 已实现 |
|
||||
> | **read/search/list 完整明细透传到 transcript(§4.9:`detailedDisplay` 提取 helper + 渲染拆分 + live/resume/replay)** | ✅ **已实现 + 测试**(方案 Y:core `getToolResponseDisplayText` + live/resume 派生 + `ToolMessage` 数据源切换;ACP 经 `transformPartsToToolCallContent` 已带全文,无需新增协议字段;截图 §3.4 待重录) |
|
||||
> | **鼠标点击工具 block 就地展开(§4.8,follow-up)** | ⏭️ **follow-up(独立 PR,不在本 PR)**——理由见 §4.8:type-based 下无 per-tool 点击目标、~250–400 行、SGR 选区风险 |
|
||||
> | 部分 | 状态 |
|
||||
> | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
> | 删除全局 compactMode(context/settings/toggle/i18n key、`mergeCompactToolGroups`) | ✅ 已实现 |
|
||||
> | `fullDetail` 管线(`HistoryItemDisplay`/`ToolGroupMessage`,并入 `forceExpandAll`/`forceShowResult`/思考块 expanded) | ✅ 已实现 |
|
||||
> | `fullDetail` 不被 `ToolGroupMessage` 两个 early return 绕过(纯并行/ memory-only 守 `!fullDetail`) | ✅ 已实现 + 回归测试 |
|
||||
> | `TranscriptView` + alt-screen 接入(Ctrl+O 开关、Esc/q/Ctrl+C 关闭、双段冻结、退出重绘、后台确认自动关闭、消息队列守卫) | ✅ 已实现 |
|
||||
> | 基于 #5661 type-based partition 的 rebase(已合入 main) | ✅ 已实现 |
|
||||
> | `AlternateScreen` 的 `process.stdout.isTTY` guard(§4.2) | ✅ 已实现 + 测试 |
|
||||
> | i18n 旧 compact 文案清理(9 语言)+ KeyboardShortcuts `ctrl+o → view transcript` 文案(§5) | ✅ 已实现 |
|
||||
> | **read/search/list 完整明细透传到 transcript(§4.9:`detailedDisplay` 提取 helper + 渲染拆分 + live/resume/replay)** | ✅ **已实现 + 测试**(方案 Y:core `getToolResponseDisplayText` + live/resume 派生 + `ToolMessage` 数据源切换;ACP 经 `transformPartsToToolCallContent` 已带全文,无需新增协议字段;截图 §3.4 待重录) |
|
||||
> | **鼠标点击工具 block 就地展开(§4.8,follow-up)** | ⏭️ **follow-up(独立 PR,不在本 PR)**——理由见 §4.8:type-based 下无 per-tool 点击目标、~250–400 行、SGR 选区风险 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -386,7 +386,7 @@ claude code 的机制是"**存储层保留完整、显示层按 `verbose` 截断
|
|||
|
||||
| 文件 | 动作 |
|
||||
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `packages/cli/src/config/keyBindings.ts` | 删 `TOGGLE_COMPACT_MODE`;加 `TOGGLE_TRANSCRIPT='toggleTranscript'`,默认绑 Ctrl+O;保留 `SHOW_MORE_LINES`(Ctrl+S) 不动。启动迁移检测**当前不适用**——代码库无用户可配置 keybinding 覆盖(`keyMatchers` 恒用硬编码默认值),无残留绑定可扫描(见 §6) |
|
||||
| `packages/cli/src/config/keyBindings.ts` | 删 `TOGGLE_COMPACT_MODE`;加 `TOGGLE_TRANSCRIPT='toggleTranscript'`,默认绑 Ctrl+O;保留 `SHOW_MORE_LINES`(Ctrl+S) 不动。启动迁移检测**当前不适用**——代码库无用户可配置 keybinding 覆盖(`keyMatchers` 恒用硬编码默认值),无残留绑定可扫描(见 §6) |
|
||||
| `packages/cli/src/ui/keyMatchers.ts` | 同步增删 matcher |
|
||||
| `packages/cli/src/ui/AppContainer.tsx` | 删 `compactMode/compactInline/setCompactMode` 状态、`CompactModeProvider`、Ctrl+O 旧分支、`compactToggleHasVisualEffect` 调用;加 `isTranscriptOpen`(canonical useState,§4.2)+ `isTranscriptOpenRef` + `transcriptFreeze` + `toggleTranscript/openTranscript/closeTranscript`;全局 Ctrl+O→`toggleTranscript`;Esc/q/Ctrl+C **handleGlobalKeypress 第一分支**关闭(早于 QUIT/EXIT/ESCAPE 及 vim INSERT 守卫,§4.3);`useEffect` 监听**全部阻塞确认/对话框**自动关闭(§4.6 #1);消息队列 drain 守卫加 `\|\| isTranscriptOpenRef.current`(§4.6 #3);`refreshStatic`用`isTranscriptOpenRef`守卫、退出时重绘一次(§4.4)。**不并入 `dialogsVisible`,不改 `useDialogClose`** |
|
||||
| `packages/cli/src/ui/contexts/UIStateContext.tsx` | **不改**:transcript 态留 `AppContainer` 本地、顶层消费,**不 surface**(取证:ThinkingViewer / claude-code REPL-local 先例,§4.2)。实现代码已如此 |
|
||||
|
|
|
|||
|
|
@ -77,9 +77,7 @@ describe('<TranscriptView />', () => {
|
|||
|
||||
it('enters and exits the alternate screen by default (useAlternateScreen defaults to true)', () => {
|
||||
setTTY(true);
|
||||
const { unmount } = renderWithProviders(
|
||||
<TranscriptView items={items} />,
|
||||
);
|
||||
const { unmount } = renderWithProviders(<TranscriptView items={items} />);
|
||||
// The default path drives AlternateScreen with disabled=false, which writes
|
||||
// the enter-alt-screen escape on mount.
|
||||
expect(writeRaw).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -53,12 +53,10 @@ const startSseServer = async (host: string, port: number) => {
|
|||
|
||||
app.get('/mcp', (req, res) => {
|
||||
if (transport) {
|
||||
res
|
||||
.status(409)
|
||||
.json({
|
||||
error:
|
||||
'Another client is already connected. Disconnect the existing client first.',
|
||||
});
|
||||
res.status(409).json({
|
||||
error:
|
||||
'Another client is already connected. Disconnect the existing client first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ import {
|
|||
type WebShellMarkdownCustomization,
|
||||
type ToolHeaderExtraRenderer,
|
||||
type UserMessageContentRenderer,
|
||||
type UserMessageContentParser,
|
||||
type AssistantTurnFooterRenderer,
|
||||
type WelcomeHeaderRenderer,
|
||||
type WelcomeFooterRenderer,
|
||||
|
|
@ -185,6 +186,9 @@ import {
|
|||
type MarkdownTableMode,
|
||||
type WebShellTaskInfo,
|
||||
type WebShellAtProvider,
|
||||
type WebShellBuiltinAtProvidersConfig,
|
||||
type ComposerTagClickHandler,
|
||||
type ComposerTagRenderer,
|
||||
type WebShellComposerTagIconMap,
|
||||
type WebShellBottomStatusItem,
|
||||
} from './customization';
|
||||
|
|
@ -415,6 +419,8 @@ export interface WebShellProps {
|
|||
hiddenSlashCommands?: string[];
|
||||
/** Slash command category order. Defaults to custom, skill, system. */
|
||||
slashCommandCategoryOrder?: CommandDisplayCategoryOrder;
|
||||
/** Built-in @ mention providers to enable. Defaults to all built-ins. */
|
||||
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
|
||||
/** Additional @ mention categories shown alongside built-in files/extensions. */
|
||||
atProviders?: readonly WebShellAtProvider[];
|
||||
/** Icon URLs for custom composer tag kinds used by @ mention chips. */
|
||||
|
|
@ -425,8 +431,16 @@ export interface WebShellProps {
|
|||
renderWelcomeHeader?: WelcomeHeaderRenderer;
|
||||
/** Custom renderer shown below the chat composer in the empty welcome state. */
|
||||
renderWelcomeFooter?: WelcomeFooterRenderer;
|
||||
/** Parse user-message text into display parts such as chips. */
|
||||
parseUserMessageContent?: UserMessageContentParser;
|
||||
/** Custom renderer for the inside of user chat bubbles. Defaults to plain text. */
|
||||
renderUserMessageContent?: UserMessageContentRenderer;
|
||||
/** Custom renderer for composer and user-message tags. */
|
||||
renderComposerTag?: ComposerTagRenderer;
|
||||
/** Custom hover content for composer and user-message tags. */
|
||||
renderComposerTagTooltip?: ComposerTagRenderer;
|
||||
/** Click handler for composer and user-message tags. */
|
||||
onComposerTagClick?: ComposerTagClickHandler;
|
||||
/** Custom renderer displayed after the final assistant message of each turn. */
|
||||
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
|
||||
/** Custom renderer inserted before the built-in chat composer toolbar controls. */
|
||||
|
|
@ -839,12 +853,17 @@ export function App({
|
|||
onBugReport,
|
||||
hiddenSlashCommands,
|
||||
slashCommandCategoryOrder,
|
||||
builtinAtProviders,
|
||||
atProviders,
|
||||
composerTagIcons,
|
||||
renderToolHeaderExtra,
|
||||
renderWelcomeHeader,
|
||||
renderWelcomeFooter,
|
||||
parseUserMessageContent,
|
||||
renderUserMessageContent,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
renderAssistantTurnFooter,
|
||||
renderComposerToolbarStart,
|
||||
renderComposerToolbarEnd,
|
||||
|
|
@ -961,7 +980,12 @@ export function App({
|
|||
renderToolHeaderExtra,
|
||||
renderWelcomeHeader,
|
||||
renderWelcomeFooter,
|
||||
parseUserMessageContent,
|
||||
renderUserMessageContent,
|
||||
composerTagIcons,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
renderAssistantTurnFooter,
|
||||
renderComposerToolbarStart,
|
||||
renderComposerToolbarEnd,
|
||||
|
|
@ -977,7 +1001,12 @@ export function App({
|
|||
renderToolHeaderExtra,
|
||||
renderWelcomeHeader,
|
||||
renderWelcomeFooter,
|
||||
parseUserMessageContent,
|
||||
renderUserMessageContent,
|
||||
composerTagIcons,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
renderAssistantTurnFooter,
|
||||
renderComposerToolbarStart,
|
||||
renderComposerToolbarEnd,
|
||||
|
|
@ -5156,6 +5185,7 @@ export function App({
|
|||
commands={commands}
|
||||
skills={loadedSkills}
|
||||
slashCommandCategoryOrder={slashCommandCategoryOrder}
|
||||
builtinAtProviders={builtinAtProviders}
|
||||
atProviders={atProviders}
|
||||
composerTagIcons={composerTagIcons}
|
||||
queuedMessages={queuedTexts}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { act } from 'react';
|
|||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { AtMentionPanel } from './AtMentionPanel';
|
||||
import type { AtMentionMenuState } from '../hooks/useAtMentionMenu';
|
||||
import type { WebShellAtProvider } from '../customization';
|
||||
import { I18nProvider } from '../i18n';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
|
@ -20,20 +21,34 @@ let container: HTMLDivElement | null = null;
|
|||
let anchor: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
function providerView(provider: WebShellAtProvider) {
|
||||
return {
|
||||
id: provider.id,
|
||||
provider,
|
||||
label: provider.label,
|
||||
textValue:
|
||||
provider.textValue ??
|
||||
(typeof provider.label === 'string' ? provider.label : provider.id),
|
||||
description: provider.description,
|
||||
tabs: provider.tabs,
|
||||
renderItem: provider.renderItem,
|
||||
};
|
||||
}
|
||||
|
||||
function categoriesMenu(): AtMentionMenuState {
|
||||
const provider: WebShellAtProvider = {
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
description: 'Reference workspace files',
|
||||
search: async () => [],
|
||||
};
|
||||
return {
|
||||
from: 0,
|
||||
to: 1,
|
||||
query: '',
|
||||
level: 'categories',
|
||||
selectedIndex: 0,
|
||||
providers: [
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
description: 'Reference workspace files',
|
||||
},
|
||||
],
|
||||
providers: [providerView(provider)],
|
||||
items: [],
|
||||
loading: false,
|
||||
};
|
||||
|
|
@ -54,6 +69,35 @@ function itemsMenu(): AtMentionMenuState {
|
|||
};
|
||||
}
|
||||
|
||||
function tabbedItemsMenu(): AtMentionMenuState {
|
||||
const provider: WebShellAtProvider = {
|
||||
id: 'tables',
|
||||
label: 'Tables',
|
||||
tabs: [
|
||||
{ id: 'mc', label: 'MaxCompute' },
|
||||
{ id: 'hg', label: 'Hologres' },
|
||||
],
|
||||
search: async () => [],
|
||||
};
|
||||
return {
|
||||
...categoriesMenu(),
|
||||
level: 'items',
|
||||
selectedProviderId: 'tables',
|
||||
providers: [providerView(provider)],
|
||||
tabs: provider.tabs,
|
||||
selectedTabId: 'mc',
|
||||
items: [
|
||||
{
|
||||
id: 'orders',
|
||||
label: 'orders',
|
||||
subtitle: 'project_a',
|
||||
description: 'daily order table',
|
||||
insertText: '@orders ',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function mount(menu: AtMentionMenuState, handlers = {}) {
|
||||
container = document.createElement('div');
|
||||
anchor = document.createElement('div');
|
||||
|
|
@ -81,6 +125,7 @@ function mount(menu: AtMentionMenuState, handlers = {}) {
|
|||
onAccept={vi.fn()}
|
||||
onBack={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onSelectTab={vi.fn()}
|
||||
{...handlers}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
|
|
@ -176,6 +221,97 @@ describe('AtMentionPanel', () => {
|
|||
expect(onSearch).toHaveBeenCalledWith('read');
|
||||
});
|
||||
|
||||
it('renders provider tabs and item subtitle fields', () => {
|
||||
const onSelectTab = vi.fn();
|
||||
mount(tabbedItemsMenu(), { onSelectTab });
|
||||
|
||||
expect(document.body.textContent).toContain('MaxCompute');
|
||||
expect(document.body.textContent).toContain('Hologres');
|
||||
expect(document.body.textContent).toContain('orders');
|
||||
expect(document.body.textContent).toContain('project_a');
|
||||
expect(document.body.textContent).toContain('daily order table');
|
||||
|
||||
const tabs = [...document.body.querySelectorAll('[role="tab"]')];
|
||||
act(() => {
|
||||
tabs[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onSelectTab).toHaveBeenCalledWith('hg');
|
||||
});
|
||||
|
||||
it('guards image icon sources', () => {
|
||||
const menu = itemsMenu();
|
||||
menu.items = [
|
||||
{
|
||||
id: 'safe',
|
||||
label: 'Safe image',
|
||||
icon: 'data:image/png;base64,iVBOR',
|
||||
iconMode: 'image',
|
||||
},
|
||||
{
|
||||
id: 'unsafe',
|
||||
label: 'Unsafe image',
|
||||
icon: 'javascript:alert(1)',
|
||||
iconMode: 'image',
|
||||
},
|
||||
];
|
||||
mount(menu);
|
||||
|
||||
const images = [...document.body.querySelectorAll('img')];
|
||||
expect(images).toHaveLength(1);
|
||||
expect(images[0]?.getAttribute('src')).toBe('data:image/png;base64,iVBOR');
|
||||
});
|
||||
|
||||
it('guards mask icon sources', () => {
|
||||
const menu = itemsMenu();
|
||||
menu.items = [
|
||||
{
|
||||
id: 'safe',
|
||||
label: 'Safe mask',
|
||||
icon: 'data:image/png;base64,iVBOR',
|
||||
},
|
||||
{
|
||||
id: 'unsafe',
|
||||
label: 'Unsafe mask',
|
||||
icon: 'javascript:alert(1)',
|
||||
},
|
||||
];
|
||||
mount(menu);
|
||||
|
||||
const icons = [
|
||||
...document.body.querySelectorAll('[style*="--at-item-icon-url"]'),
|
||||
];
|
||||
expect(icons).toHaveLength(1);
|
||||
expect(icons[0]?.getAttribute('style')).toContain(
|
||||
'data:image/png;base64,iVBOR',
|
||||
);
|
||||
expect(document.body.innerHTML).not.toContain('javascript:alert');
|
||||
});
|
||||
|
||||
it('falls back when a custom item renderer throws', () => {
|
||||
const error = new Error('bad item');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const menu = itemsMenu();
|
||||
const provider: WebShellAtProvider = {
|
||||
id: 'files',
|
||||
label: 'Files',
|
||||
renderItem: () => {
|
||||
throw error;
|
||||
},
|
||||
search: async () => [],
|
||||
};
|
||||
menu.providers = [providerView(provider)];
|
||||
|
||||
mount(menu);
|
||||
|
||||
expect(document.body.textContent).toContain('README.md');
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[WebShell] at mention item render failed',
|
||||
error,
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('focuses search when explicitly requested', () => {
|
||||
vi.useFakeTimers();
|
||||
mount({ ...itemsMenu(), inputMode: 'search' });
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
useState,
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useI18n } from '../i18n';
|
||||
|
|
@ -13,7 +14,9 @@ import {
|
|||
sanitizeDisplayText,
|
||||
type AtMentionMenuState,
|
||||
} from '../hooks/useAtMentionMenu';
|
||||
import { cssUrlVar } from '../utils/cssUrlVar';
|
||||
import styles from './ChatEditor.module.css';
|
||||
import { isSafeImageSrc } from './messages/Markdown';
|
||||
|
||||
const AT_PANEL_THEME_VARS = [
|
||||
'--chat-editor-accent-color',
|
||||
|
|
@ -34,6 +37,7 @@ export function AtMentionPanel({
|
|||
onAccept,
|
||||
onBack,
|
||||
onSearch,
|
||||
onSelectTab,
|
||||
}: {
|
||||
menu: AtMentionMenuState;
|
||||
anchorRef: RefObject<HTMLElement | null>;
|
||||
|
|
@ -42,14 +46,17 @@ export function AtMentionPanel({
|
|||
onAccept: (index?: number) => boolean;
|
||||
onBack: () => boolean;
|
||||
onSearch: (query: string) => boolean;
|
||||
onSelectTab: (tabId: string) => boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const tabsRef = useRef<HTMLDivElement | null>(null);
|
||||
const [anchorRect, setAnchorRect] = useState<{
|
||||
left: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
const [themeVars, setThemeVars] = useState<CSSProperties>({});
|
||||
|
||||
|
|
@ -99,6 +106,12 @@ export function AtMentionPanel({
|
|||
const updatePosition = () => {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const panelWidth = panelRef.current?.offsetWidth ?? 360;
|
||||
const computedStyle = getComputedStyle(anchor);
|
||||
const safeTop =
|
||||
Number.parseFloat(
|
||||
computedStyle.getPropertyValue('--web-shell-popover-safe-top'),
|
||||
) || 48;
|
||||
const maxHeight = Math.max(96, Math.min(300, rect.top - safeTop - 8));
|
||||
const next = {
|
||||
left: Math.max(
|
||||
12,
|
||||
|
|
@ -106,13 +119,15 @@ export function AtMentionPanel({
|
|||
),
|
||||
bottom: window.innerHeight - rect.top + 8,
|
||||
width: rect.width,
|
||||
maxHeight,
|
||||
};
|
||||
setAnchorRect((prev) => {
|
||||
if (
|
||||
prev &&
|
||||
prev.left === next.left &&
|
||||
prev.bottom === next.bottom &&
|
||||
prev.width === next.width
|
||||
prev.width === next.width &&
|
||||
prev.maxHeight === next.maxHeight
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
|
@ -147,18 +162,28 @@ export function AtMentionPanel({
|
|||
? menu.providers.map((provider) => ({
|
||||
id: provider.id,
|
||||
label: provider.label,
|
||||
labelTitle: provider.textValue,
|
||||
description: provider.description,
|
||||
trailing: '›',
|
||||
provider,
|
||||
}))
|
||||
: menu.items.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
labelTitle: item.label,
|
||||
subtitle: item.subtitle,
|
||||
description:
|
||||
menu.selectedProviderId === FILE_PROVIDER_ID
|
||||
? undefined
|
||||
: (item.description ?? item.detail),
|
||||
icon: item.icon,
|
||||
iconMode: item.iconMode,
|
||||
iconColor: item.iconColor,
|
||||
iconSpin: item.iconSpin,
|
||||
iconTooltip: item.iconTooltip,
|
||||
trailing:
|
||||
item.kind === 'directory' || item.kind === 'mcp-server' ? '›' : '',
|
||||
item,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -174,9 +199,13 @@ export function AtMentionPanel({
|
|||
menu.itemMode === 'mcpResources' && menu.mcpServerName
|
||||
? (sanitizeDisplayText(menu.mcpServerName) ?? '[invalid]')
|
||||
: (selectedProvider?.label ?? '');
|
||||
const panelTitleText =
|
||||
menu.itemMode === 'mcpResources' && menu.mcpServerName
|
||||
? (sanitizeDisplayText(menu.mcpServerName) ?? '[invalid]')
|
||||
: (selectedProvider?.textValue ?? '');
|
||||
const listboxLabel =
|
||||
menu.level === 'items'
|
||||
? (selectedProvider?.label ?? t('at.menu'))
|
||||
? (selectedProvider?.textValue ?? t('at.menu'))
|
||||
: t('at.menu');
|
||||
const listboxId = 'at-mention-listbox';
|
||||
const activeOptionId =
|
||||
|
|
@ -196,6 +225,7 @@ export function AtMentionPanel({
|
|||
left: anchorRect.left,
|
||||
bottom: anchorRect.bottom,
|
||||
'--at-anchor-width': `${anchorRect.width}px`,
|
||||
'--at-panel-max-height': `${anchorRect.maxHeight}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
role="region"
|
||||
|
|
@ -224,7 +254,9 @@ export function AtMentionPanel({
|
|||
>
|
||||
‹
|
||||
</button>
|
||||
<span className={styles.atPanelTitle}>{panelTitle}</span>
|
||||
<span className={styles.atPanelTitle} title={panelTitleText}>
|
||||
{panelTitle}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
|
|
@ -275,6 +307,62 @@ export function AtMentionPanel({
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{menu.tabs && menu.tabs.length > 0 && (
|
||||
<div className={styles.atTabsWrap}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.atTabScrollButton}
|
||||
aria-label="Previous tab"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
scrollTabs(tabsRef.current, 'previous');
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<div ref={tabsRef} className={styles.atTabs} role="tablist">
|
||||
{menu.tabs.map((tab) => {
|
||||
const selected = tab.id === menu.selectedTabId;
|
||||
const tabText =
|
||||
tab.textValue ??
|
||||
(typeof tab.label === 'string' ? tab.label : tab.id);
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
disabled={tab.disabled}
|
||||
className={`${styles.atTab} ${
|
||||
selected ? styles.atTabActive : ''
|
||||
}`}
|
||||
title={tabText}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSelectTab(tab.id);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.atTabScrollButton}
|
||||
aria-label="Next tab"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
scrollTabs(tabsRef.current, 'next');
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
|
@ -292,43 +380,124 @@ export function AtMentionPanel({
|
|||
{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>
|
||||
))
|
||||
rows.map((row, index) => {
|
||||
const selected = index === menu.selectedIndex;
|
||||
const provider =
|
||||
'item' in row
|
||||
? selectedProvider?.provider
|
||||
: row.provider.provider;
|
||||
let customItem: ReactNode | undefined;
|
||||
if ('item' in row && provider?.renderItem) {
|
||||
try {
|
||||
customItem = provider.renderItem({
|
||||
item: row.item,
|
||||
provider,
|
||||
selected,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[WebShell] at mention item render failed',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
const safeIcon =
|
||||
'icon' in row && row.icon && isSafeImageSrc(row.icon)
|
||||
? row.icon
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={row.id}
|
||||
ref={(node) => {
|
||||
itemRefs.current[index] = node;
|
||||
}}
|
||||
type="button"
|
||||
id={`at-mention-option-${index}`}
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`${styles.atItem} ${
|
||||
selected ? styles.atItemActive : ''
|
||||
} ${row.description ? '' : styles.atItemSingleLine}`}
|
||||
style={
|
||||
{
|
||||
'--at-item-main-template': getAtItemMainTemplate(
|
||||
getRowTextValue(row.label, row.labelTitle),
|
||||
'subtitle' in row ? row.subtitle : undefined,
|
||||
),
|
||||
} as CSSProperties
|
||||
}
|
||||
onMouseEnter={() => onSelect(index)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onAccept(index);
|
||||
}}
|
||||
>
|
||||
{customItem ?? (
|
||||
<>
|
||||
<span className={styles.atItemMain}>
|
||||
<span className={styles.atItemLeading}>
|
||||
{'icon' in row &&
|
||||
safeIcon &&
|
||||
(row.iconMode === 'image' ? (
|
||||
<img
|
||||
className={styles.atItemImageIcon}
|
||||
src={safeIcon}
|
||||
title={row.iconTooltip}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`${styles.atItemIcon} ${
|
||||
row.iconSpin ? styles.atItemIconSpin : ''
|
||||
}`}
|
||||
style={{
|
||||
...cssUrlVar('--at-item-icon-url', safeIcon),
|
||||
color: row.iconColor,
|
||||
}}
|
||||
title={row.iconTooltip}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
className={styles.atItemLabel}
|
||||
title={row.labelTitle}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
</span>
|
||||
{'subtitle' in row && row.subtitle && (
|
||||
<span
|
||||
className={styles.atItemSubtitle}
|
||||
title={row.subtitle}
|
||||
>
|
||||
{row.subtitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{row.description && (
|
||||
<span
|
||||
className={styles.atItemDescription}
|
||||
title={row.description}
|
||||
>
|
||||
{row.description}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{row.trailing && (
|
||||
<span className={styles.atItemTrailing} aria-hidden="true">
|
||||
{row.trailing}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -336,3 +505,42 @@ export function AtMentionPanel({
|
|||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function scrollTabs(
|
||||
element: HTMLDivElement | null,
|
||||
direction: 'previous' | 'next',
|
||||
): void {
|
||||
if (!element) return;
|
||||
const delta = Math.max(96, element.clientWidth * 0.75);
|
||||
element.scrollBy({
|
||||
left: direction === 'previous' ? -delta : delta,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
function getAtItemMainTemplate(
|
||||
label: string,
|
||||
subtitle: string | undefined,
|
||||
): string {
|
||||
if (!subtitle) return 'minmax(0, 1fr)';
|
||||
const labelLength = getDisplayLength(label);
|
||||
const subtitleLength = getDisplayLength(subtitle);
|
||||
if (subtitleLength <= 12) return 'minmax(0, 1fr) max-content';
|
||||
if (labelLength <= 18) return 'max-content minmax(0, 1fr)';
|
||||
return 'minmax(0, 3fr) minmax(0, 2fr)';
|
||||
}
|
||||
|
||||
function getRowTextValue(
|
||||
label: ReactNode,
|
||||
fallback: string | undefined,
|
||||
): string {
|
||||
if (typeof label === 'string') return label;
|
||||
if (typeof label === 'number') return String(label);
|
||||
return fallback || '';
|
||||
}
|
||||
|
||||
function getDisplayLength(value: string): number {
|
||||
return Array.from(value).reduce((total, char) => {
|
||||
return total + (char.charCodeAt(0) > 255 ? 2 : 1);
|
||||
}, 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,7 +381,7 @@
|
|||
--at-anchor-width: 620px;
|
||||
display: flex;
|
||||
width: min(360px, calc(var(--at-anchor-width) - 32px), calc(100vw - 24px));
|
||||
max-height: min(300px, 42vh);
|
||||
max-height: min(var(--at-panel-max-height, 300px), 42vh);
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
|
|
@ -445,6 +445,73 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.atTabsWrap {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 22px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.atTabs {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.atTabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.atTab,
|
||||
.atTabScrollButton {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.atTab {
|
||||
flex: 0 0 auto;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.atTabScrollButton {
|
||||
width: 22px;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.atTab:hover,
|
||||
.atTabActive,
|
||||
.atTabScrollButton:hover {
|
||||
background: var(--accent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.atTab:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.atSearchInput {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
|
@ -524,17 +591,78 @@
|
|||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.atItemLabel {
|
||||
.atItemMain {
|
||||
grid-area: label;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: var(--at-item-main-template, minmax(0, 1fr));
|
||||
column-gap: 8px;
|
||||
align-items: end;
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.atItemLeading {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: end;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.atItemIcon {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
background: currentColor;
|
||||
color: var(--muted-foreground);
|
||||
mask: var(--at-item-icon-url) center / contain no-repeat;
|
||||
-webkit-mask: var(--at-item-icon-url) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.atItemImageIcon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.atItemIconSpin {
|
||||
animation: at-item-icon-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.atItemLabel,
|
||||
.atItemSubtitle {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--foreground);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.atItemLabel {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.atItemSubtitle {
|
||||
color: var(--muted-foreground);
|
||||
justify-self: end;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@keyframes at-item-icon-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.atItemDescription {
|
||||
grid-area: description;
|
||||
min-width: 0;
|
||||
|
|
@ -585,6 +713,7 @@
|
|||
}
|
||||
|
||||
.tag {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
|
|
@ -598,6 +727,31 @@
|
|||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.tagTooltip {
|
||||
position: absolute;
|
||||
z-index: calc(var(--web-shell-tooltip-z-index, 1000) + 1);
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
display: none;
|
||||
min-width: 160px;
|
||||
max-width: min(320px, 80vw);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--chat-editor-border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--background);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.tag:hover .tagTooltip,
|
||||
.tag:focus-within .tagTooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tagLabel,
|
||||
.tagValue {
|
||||
color: var(--chat-editor-text-primary);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
type WebShellComposerTag,
|
||||
type WebShellComposerTagIconMap,
|
||||
type WebShellAtProvider,
|
||||
type WebShellBuiltinAtProvidersConfig,
|
||||
} from '../customization';
|
||||
import {
|
||||
useComposerCore,
|
||||
|
|
@ -35,6 +36,7 @@ import {
|
|||
import { AtMentionPanel } from './AtMentionPanel';
|
||||
import { cssUrlVar } from '../utils/cssUrlVar';
|
||||
import { getComposerTagIconUrl } from './composerTagIcons';
|
||||
import { isSafeImageSrc } from './messages/Markdown';
|
||||
import { ModeIcon } from './ModeIcon';
|
||||
import { planSlashSectionRows } from '../utils/slashSectionPlan';
|
||||
import { getModelDisplayName } from '../utils/modelDisplay';
|
||||
|
|
@ -98,6 +100,7 @@ interface ChatEditorProps {
|
|||
sessionName?: string;
|
||||
composerInput?: WebShellComposerInput;
|
||||
composerInputVersion?: number;
|
||||
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
|
||||
atProviders?: readonly WebShellAtProvider[];
|
||||
composerTagIcons?: WebShellComposerTagIconMap;
|
||||
}
|
||||
|
|
@ -924,10 +927,20 @@ export const ChatEditor = memo(
|
|||
sessionName,
|
||||
composerInput,
|
||||
composerInputVersion,
|
||||
builtinAtProviders,
|
||||
atProviders,
|
||||
composerTagIcons,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
renderComposerToolbarStart: ToolbarStart,
|
||||
renderComposerToolbarEnd: ToolbarEnd,
|
||||
renderComposerToolbarRight: ToolbarRight,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
} = useWebShellCustomization();
|
||||
|
||||
const core = useComposerCore({
|
||||
onSubmit,
|
||||
onCycleMode,
|
||||
|
|
@ -948,17 +961,16 @@ export const ChatEditor = memo(
|
|||
sessionName,
|
||||
composerInput,
|
||||
composerInputVersion,
|
||||
builtinAtProviders,
|
||||
atProviders,
|
||||
composerTagIcons,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
editorTheme: CHAT_EDITOR_THEME,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
renderComposerToolbarStart: ToolbarStart,
|
||||
renderComposerToolbarEnd: ToolbarEnd,
|
||||
renderComposerToolbarRight: ToolbarRight,
|
||||
} = useWebShellCustomization();
|
||||
|
||||
useImperativeHandle(ref, () => core.handle, [core.handle]);
|
||||
|
||||
|
|
@ -1274,19 +1286,30 @@ export const ChatEditor = memo(
|
|||
} = core.searchState;
|
||||
|
||||
const renderComposerTagContent = (tag: WebShellComposerTag) => {
|
||||
const custom = renderComposerTag?.({
|
||||
tag,
|
||||
placement: 'composer',
|
||||
readonly: false,
|
||||
});
|
||||
if (custom !== undefined && custom !== null) {
|
||||
return custom;
|
||||
}
|
||||
const rawTagLabel = getComposerTagLabel(tag);
|
||||
const tagValue = getComposerTagValue(tag);
|
||||
const tagLabel = tag.kind ? '' : rawTagLabel;
|
||||
const iconUrl = getComposerTagIconUrl(tag.kind, composerTagIcons);
|
||||
const iconUrl =
|
||||
tag.icon ?? getComposerTagIconUrl(tag.kind, composerTagIcons);
|
||||
const safeIconUrl =
|
||||
iconUrl && isSafeImageSrc(iconUrl) ? iconUrl : undefined;
|
||||
if (!tagLabel && !tagValue) {
|
||||
return <span className={styles.tagLabel}>{tag.id}</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{iconUrl && (
|
||||
{safeIconUrl && (
|
||||
<span
|
||||
className={styles.tagIcon}
|
||||
style={cssUrlVar('--composer-tag-icon-url', iconUrl)}
|
||||
style={cssUrlVar('--composer-tag-icon-url', safeIconUrl)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1377,37 +1400,79 @@ export const ChatEditor = memo(
|
|||
<div className={styles.content}>
|
||||
{core.composerTags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{core.composerTags.map((tag) => (
|
||||
<span key={tag.id} className={styles.tag}>
|
||||
{renderComposerTagContent(tag)}
|
||||
{tag.removable !== false && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tagRemove}
|
||||
aria-label={`Remove ${getComposerTagDisplay(tag)}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
core.removeTopTag(tag.id);
|
||||
core.viewRef.current?.focus();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key !== 'Backspace' &&
|
||||
event.key !== 'Delete'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
core.removeTopTag(tag.id);
|
||||
core.viewRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{core.composerTags.map((tag) => {
|
||||
const tagInfo = {
|
||||
tag,
|
||||
placement: 'composer' as const,
|
||||
readonly: false,
|
||||
};
|
||||
const tooltip = renderComposerTagTooltip?.(tagInfo);
|
||||
return (
|
||||
<span
|
||||
key={tag.id}
|
||||
className={styles.tag}
|
||||
role={onComposerTagClick ? 'button' : undefined}
|
||||
tabIndex={onComposerTagClick ? 0 : undefined}
|
||||
onClick={(event) => {
|
||||
if (!onComposerTagClick) return;
|
||||
event.stopPropagation();
|
||||
onComposerTagClick({
|
||||
...tagInfo,
|
||||
anchorRect:
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!onComposerTagClick) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onComposerTagClick({
|
||||
...tagInfo,
|
||||
anchorRect:
|
||||
event.currentTarget.getBoundingClientRect(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{renderComposerTagContent(tag)}
|
||||
{tag.removable !== false && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.tagRemove}
|
||||
aria-label={`Remove ${getComposerTagDisplay(tag)}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
core.removeTopTag(tag.id);
|
||||
core.viewRef.current?.focus();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.key !== 'Backspace' &&
|
||||
event.key !== 'Delete'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
core.removeTopTag(tag.id);
|
||||
core.viewRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{tooltip !== undefined && tooltip !== null && (
|
||||
<span className={styles.tagTooltip} role="tooltip">
|
||||
{tooltip}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{core.pastedImages.length > 0 && (
|
||||
|
|
@ -1455,6 +1520,7 @@ export const ChatEditor = memo(
|
|||
return Boolean(result);
|
||||
}}
|
||||
onSearch={core.updateAtSearch}
|
||||
onSelectTab={core.selectAtTab}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.editorArea}>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,102 @@
|
|||
word-break: break-word;
|
||||
}
|
||||
|
||||
.messageTag {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
max-width: min(44ch, 100%);
|
||||
min-height: 20px;
|
||||
align-items: center;
|
||||
margin: 0 0.25ch;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--secondary);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.messageTagTooltip {
|
||||
position: absolute;
|
||||
z-index: calc(var(--web-shell-tooltip-z-index, 1000) + 1);
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 160px;
|
||||
max-width: min(320px, 80vw);
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--background);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 120ms ease 0ms,
|
||||
visibility 0ms linear 120ms;
|
||||
visibility: hidden;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.messageTag:hover .messageTagTooltip,
|
||||
.messageTag:focus-within .messageTagTooltip {
|
||||
opacity: 1;
|
||||
transition:
|
||||
opacity 120ms ease 450ms,
|
||||
visibility 0ms linear 450ms;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.messageTagClickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.messageTagClickable:hover,
|
||||
.messageTagClickable:focus-visible {
|
||||
border-color: var(--chat-editor-accent-color, var(--foreground));
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.messageTagIcon {
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: 7px;
|
||||
background: currentColor;
|
||||
mask: var(--user-message-tag-icon-url) center / contain no-repeat;
|
||||
-webkit-mask: var(--user-message-tag-icon-url) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.messageTagLabel,
|
||||
.messageTagValue {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageTagLabel {
|
||||
padding: 3px 0 3px 7px;
|
||||
color: var(--agent-blue-500);
|
||||
}
|
||||
|
||||
.messageTagIcon + .messageTagLabel {
|
||||
padding-left: 0.5ch;
|
||||
}
|
||||
|
||||
.messageTagValue {
|
||||
max-width: 32ch;
|
||||
padding: 3px 0 3px 0.5ch;
|
||||
color: var(--foreground, #e4e4e4);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chatContentCollapsed {
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, type ReactNode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { WebShellCustomizationProvider } from '../../customization';
|
||||
|
|
@ -64,4 +64,225 @@ describe('UserMessage', () => {
|
|||
expect(container.querySelector('[data-testid="tag-chip"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain('.husky/_/husky.sh xuyao');
|
||||
});
|
||||
|
||||
it('renders parsed user-message tag parts', () => {
|
||||
const container = render(
|
||||
<WebShellCustomizationProvider
|
||||
value={{
|
||||
parseUserMessageContent: () => [
|
||||
{ type: 'text', text: 'open ' },
|
||||
{
|
||||
type: 'tag',
|
||||
tag: {
|
||||
id: 'ctx-1',
|
||||
label: 'Table',
|
||||
value: 'orders',
|
||||
serialized: '<context />',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<UserMessage content="open <context />" />
|
||||
</WebShellCustomizationProvider>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('open ');
|
||||
expect(container.textContent).toContain('Table');
|
||||
expect(container.textContent).toContain('orders');
|
||||
});
|
||||
|
||||
it('guards parsed tag mask icon sources', () => {
|
||||
const container = render(
|
||||
<WebShellCustomizationProvider
|
||||
value={{
|
||||
parseUserMessageContent: () => [
|
||||
{
|
||||
type: 'tag',
|
||||
tag: {
|
||||
id: 'ctx-1',
|
||||
label: 'Table',
|
||||
value: 'orders',
|
||||
icon: 'javascript:alert(1)',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<UserMessage content="<context />" />
|
||||
</WebShellCustomizationProvider>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('orders');
|
||||
expect(container.innerHTML).not.toContain('javascript:alert');
|
||||
expect(
|
||||
container.querySelector('[style*="--user-message-tag-icon-url"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders kind-based tags like composer chips without the raw label', () => {
|
||||
const container = render(
|
||||
<WebShellCustomizationProvider
|
||||
value={{
|
||||
parseUserMessageContent: () => [
|
||||
{ type: 'text', text: 'explain ' },
|
||||
{
|
||||
type: 'tag',
|
||||
tag: {
|
||||
id: 'ctx-1',
|
||||
label: '@',
|
||||
value: 'project.orders',
|
||||
kind: 'table',
|
||||
serialized: '<context />',
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: ' now' },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<UserMessage content="explain <context /> now" />
|
||||
</WebShellCustomizationProvider>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe('explain project.orders now');
|
||||
expect(container.querySelector('[title="project.orders"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('uses custom composer tag icons for parsed user-message tags', () => {
|
||||
const container = render(
|
||||
<WebShellCustomizationProvider
|
||||
value={{
|
||||
composerTagIcons: {
|
||||
table: 'https://example.test/table.svg',
|
||||
},
|
||||
parseUserMessageContent: () => [
|
||||
{
|
||||
type: 'tag',
|
||||
tag: {
|
||||
id: 'ctx-1',
|
||||
value: 'project.orders',
|
||||
kind: 'table',
|
||||
serialized: '<context />',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<UserMessage content="<context />" />
|
||||
</WebShellCustomizationProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('[style*="https://example.test/table.svg"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('fires user-message tag clicks with the user-message placement', () => {
|
||||
const onComposerTagClick = vi.fn();
|
||||
const container = render(
|
||||
<WebShellCustomizationProvider
|
||||
value={{
|
||||
onComposerTagClick,
|
||||
parseUserMessageContent: () => [
|
||||
{
|
||||
type: 'tag',
|
||||
tag: {
|
||||
id: 'ctx-1',
|
||||
value: 'project.orders',
|
||||
kind: 'table',
|
||||
serialized: '<context />',
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<UserMessage content="<context />" />
|
||||
</WebShellCustomizationProvider>,
|
||||
);
|
||||
const chip = container.querySelector('[role="button"]') as HTMLElement;
|
||||
|
||||
act(() => chip.click());
|
||||
expect(onComposerTagClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: 'user-message',
|
||||
readonly: true,
|
||||
tag: expect.objectContaining({ id: 'ctx-1' }),
|
||||
anchorRect: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
chip.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
});
|
||||
expect(onComposerTagClick).toHaveBeenCalledTimes(2);
|
||||
expect(onComposerTagClick).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: 'user-message',
|
||||
readonly: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to raw content when user-message parsing throws', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const container = render(
|
||||
<WebShellCustomizationProvider
|
||||
value={{
|
||||
parseUserMessageContent: () => {
|
||||
throw new Error('bad host payload');
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserMessage content="raw <broken /> content" />
|
||||
</WebShellCustomizationProvider>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe('raw <broken /> content');
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back when user-message tag rendering throws', () => {
|
||||
const renderError = new Error('bad tag renderer');
|
||||
const tooltipError = new Error('bad tag tooltip');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const container = render(
|
||||
<WebShellCustomizationProvider
|
||||
value={{
|
||||
parseUserMessageContent: () => [
|
||||
{
|
||||
type: 'tag',
|
||||
tag: {
|
||||
id: 'ctx-1',
|
||||
label: 'Table',
|
||||
value: 'orders',
|
||||
serialized: '<context />',
|
||||
},
|
||||
},
|
||||
],
|
||||
renderComposerTag: () => {
|
||||
throw renderError;
|
||||
},
|
||||
renderComposerTagTooltip: () => {
|
||||
throw tooltipError;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UserMessage content="<context />" />
|
||||
</WebShellCustomizationProvider>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Table');
|
||||
expect(container.textContent).toContain('orders');
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[WebShell] user message tag render failed',
|
||||
renderError,
|
||||
);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[WebShell] user message tag tooltip render failed',
|
||||
tooltipError,
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,10 +6,25 @@ import {
|
|||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { isSafeImageSrc } from './Markdown';
|
||||
import { useWebShellCustomization } from '../../customization';
|
||||
import type {
|
||||
ComposerTagClickHandler,
|
||||
ComposerTagRenderer,
|
||||
WebShellComposerTag,
|
||||
WebShellComposerTagIconMap,
|
||||
WebShellUserMessagePart,
|
||||
} from '../../customization';
|
||||
import {
|
||||
getComposerTagDisplay,
|
||||
getComposerTagLabel,
|
||||
getComposerTagValue,
|
||||
} from '../../hooks/useComposerCore';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { cssUrlVar } from '../../utils/cssUrlVar';
|
||||
import { getComposerTagIconUrl } from '../composerTagIcons';
|
||||
import flashStyles from '../MessageLocateFlash.module.css';
|
||||
import styles from './UserMessage.module.css';
|
||||
|
||||
|
|
@ -30,14 +45,51 @@ export const UserMessage = memo(function UserMessage({
|
|||
isLocateFlashing = false,
|
||||
}: UserMessageProps) {
|
||||
const { t } = useI18n();
|
||||
const { renderUserMessageContent } = useWebShellCustomization();
|
||||
const {
|
||||
parseUserMessageContent,
|
||||
renderUserMessageContent,
|
||||
composerTagIcons,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
} = useWebShellCustomization();
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [heightOverflowing, setHeightOverflowing] = useState(false);
|
||||
const renderedContent = useMemo(
|
||||
() => renderUserMessageContent?.({ content, images }) ?? content,
|
||||
[content, images, renderUserMessageContent],
|
||||
);
|
||||
const renderedContent = useMemo(() => {
|
||||
const explicit = renderUserMessageContent?.({ content, images });
|
||||
if (explicit !== undefined && explicit !== null) return explicit;
|
||||
let parts: readonly WebShellUserMessagePart[] | undefined | null;
|
||||
try {
|
||||
parts = parseUserMessageContent?.(content);
|
||||
} catch (error) {
|
||||
console.warn('[WebShell] failed to parse user message content', error);
|
||||
return content;
|
||||
}
|
||||
if (!parts || parts.length === 0) return content;
|
||||
return parts.map((part, index) => {
|
||||
if (part.type === 'text') return part.text;
|
||||
return (
|
||||
<UserMessageTag
|
||||
key={`${part.tag.id}-${index}`}
|
||||
tag={part.tag}
|
||||
composerTagIcons={composerTagIcons}
|
||||
renderComposerTag={renderComposerTag}
|
||||
renderComposerTagTooltip={renderComposerTagTooltip}
|
||||
onComposerTagClick={onComposerTagClick}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [
|
||||
content,
|
||||
images,
|
||||
onComposerTagClick,
|
||||
parseUserMessageContent,
|
||||
composerTagIcons,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
renderUserMessageContent,
|
||||
]);
|
||||
|
||||
const measureOverflow = useCallback(() => {
|
||||
const el = contentRef.current;
|
||||
|
|
@ -122,3 +174,93 @@ export const UserMessage = memo(function UserMessage({
|
|||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function getTagText(tag: WebShellComposerTag): string {
|
||||
return getComposerTagDisplay(tag);
|
||||
}
|
||||
|
||||
function UserMessageTag({
|
||||
tag,
|
||||
composerTagIcons,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
}: {
|
||||
tag: WebShellComposerTag;
|
||||
composerTagIcons: WebShellComposerTagIconMap | undefined;
|
||||
renderComposerTag: ComposerTagRenderer | undefined;
|
||||
renderComposerTagTooltip: ComposerTagRenderer | undefined;
|
||||
onComposerTagClick: ComposerTagClickHandler | undefined;
|
||||
}) {
|
||||
const info = { tag, placement: 'user-message' as const, readonly: true };
|
||||
let custom: ReactNode | null | undefined;
|
||||
let tooltip: ReactNode | null | undefined;
|
||||
try {
|
||||
custom = renderComposerTag?.(info);
|
||||
} catch (error) {
|
||||
console.warn('[WebShell] user message tag render failed', error);
|
||||
}
|
||||
try {
|
||||
tooltip = renderComposerTagTooltip?.(info);
|
||||
} catch (error) {
|
||||
console.warn('[WebShell] user message tag tooltip render failed', error);
|
||||
}
|
||||
const clickable = Boolean(onComposerTagClick);
|
||||
const rawTagLabel = getComposerTagLabel(tag);
|
||||
const tagValue = getComposerTagValue(tag);
|
||||
const tagLabel = tag.kind ? '' : rawTagLabel;
|
||||
const iconUrl = tag.icon ?? getComposerTagIconUrl(tag.kind, composerTagIcons);
|
||||
const safeIconUrl = iconUrl && isSafeImageSrc(iconUrl) ? iconUrl : undefined;
|
||||
return (
|
||||
<span
|
||||
className={`${styles.messageTag}${
|
||||
clickable ? ` ${styles.messageTagClickable}` : ''
|
||||
}`}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
title={getTagText(tag)}
|
||||
onClick={(event) => {
|
||||
if (!clickable) return;
|
||||
event.stopPropagation();
|
||||
onComposerTagClick?.({
|
||||
...info,
|
||||
anchorRect: event.currentTarget.getBoundingClientRect(),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!clickable) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
onComposerTagClick?.({
|
||||
...info,
|
||||
anchorRect: event.currentTarget.getBoundingClientRect(),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{custom ?? (
|
||||
<>
|
||||
{safeIconUrl && (
|
||||
<span
|
||||
className={styles.messageTagIcon}
|
||||
style={cssUrlVar('--user-message-tag-icon-url', safeIconUrl)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
{tagLabel && (
|
||||
<span className={styles.messageTagLabel}>{tagLabel}</span>
|
||||
)}
|
||||
{tagValue ? (
|
||||
<span className={styles.messageTagValue}>{tagValue}</span>
|
||||
) : !tagLabel ? (
|
||||
<span className={styles.messageTagLabel}>{tag.id}</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{tooltip !== undefined && tooltip !== null && (
|
||||
<span className={styles.messageTagTooltip} role="tooltip">
|
||||
{tooltip}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export interface WebShellBottomStatusItem {
|
|||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export type WebShellIconSource = string;
|
||||
|
||||
export interface WebShellAssistantMessageInfo {
|
||||
id: string;
|
||||
content: string;
|
||||
|
|
@ -142,9 +144,45 @@ export interface WebShellComposerTag {
|
|||
value?: string;
|
||||
removable?: boolean;
|
||||
kind?: WebShellComposerTagKind;
|
||||
icon?: WebShellIconSource;
|
||||
metadata?: unknown;
|
||||
serialized?: string;
|
||||
}
|
||||
|
||||
export type WebShellComposerTagPlacementContext = 'composer' | 'user-message';
|
||||
|
||||
export interface WebShellComposerTagRenderInfo {
|
||||
tag: WebShellComposerTag;
|
||||
placement: WebShellComposerTagPlacementContext;
|
||||
readonly: boolean;
|
||||
anchorRect?: DOMRectReadOnly;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom composer tag content. Inline composer tags are mounted from
|
||||
* CodeMirror-managed React roots, so JSX returned for inline tags must not
|
||||
* depend on React context from the surrounding app tree.
|
||||
*/
|
||||
export type ComposerTagRenderer = (
|
||||
info: WebShellComposerTagRenderInfo,
|
||||
) => ReactNode | null | undefined;
|
||||
|
||||
export type ComposerTagClickHandler = (
|
||||
info: WebShellComposerTagRenderInfo,
|
||||
) => void;
|
||||
|
||||
export type WebShellUserMessagePart =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'tag';
|
||||
tag: WebShellComposerTag;
|
||||
sourceRange?: readonly [number, number];
|
||||
};
|
||||
|
||||
export type UserMessageContentParser = (
|
||||
content: string,
|
||||
) => readonly WebShellUserMessagePart[] | undefined | null;
|
||||
|
||||
export type WebShellComposerTagPlacement = 'top' | 'inline';
|
||||
|
||||
export interface WebShellComposerTagOptions {
|
||||
|
|
@ -165,20 +203,61 @@ export interface WebShellComposerInput {
|
|||
export interface WebShellAtItem {
|
||||
id: string;
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
detail?: string;
|
||||
icon?: WebShellIconSource;
|
||||
iconMode?: 'mask' | 'image';
|
||||
iconColor?: string;
|
||||
iconSpin?: boolean;
|
||||
iconTooltip?: string;
|
||||
insertText?: string;
|
||||
composerTag?: WebShellComposerTag;
|
||||
}
|
||||
|
||||
export type WebShellBuiltinAtProviderId =
|
||||
| 'files'
|
||||
| 'extensions'
|
||||
| 'mcp-resources';
|
||||
|
||||
export type WebShellBuiltinAtProvidersConfig =
|
||||
| boolean
|
||||
| readonly WebShellBuiltinAtProviderId[]
|
||||
| {
|
||||
enabled?: boolean;
|
||||
include?: readonly WebShellBuiltinAtProviderId[];
|
||||
exclude?: readonly WebShellBuiltinAtProviderId[];
|
||||
};
|
||||
|
||||
export interface WebShellAtProviderTab {
|
||||
id: string;
|
||||
label: ReactNode;
|
||||
textValue?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface WebShellAtItemRenderInfo {
|
||||
item: WebShellAtItem;
|
||||
provider: WebShellAtProvider;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export type WebShellAtItemRenderer = (
|
||||
info: WebShellAtItemRenderInfo,
|
||||
) => ReactNode | null | undefined;
|
||||
|
||||
export interface WebShellAtProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
label: ReactNode;
|
||||
textValue?: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
tabs?: readonly WebShellAtProviderTab[];
|
||||
renderItem?: WebShellAtItemRenderer;
|
||||
search(params: {
|
||||
query: string;
|
||||
signal: AbortSignal;
|
||||
tabId?: string;
|
||||
}): Promise<readonly WebShellAtItem[]>;
|
||||
}
|
||||
|
||||
|
|
@ -311,7 +390,12 @@ export interface WebShellCustomization {
|
|||
renderToolHeaderExtra?: ToolHeaderExtraRenderer;
|
||||
renderWelcomeHeader?: WelcomeHeaderRenderer;
|
||||
renderWelcomeFooter?: WelcomeFooterRenderer;
|
||||
parseUserMessageContent?: UserMessageContentParser;
|
||||
renderUserMessageContent?: UserMessageContentRenderer;
|
||||
composerTagIcons?: WebShellComposerTagIconMap;
|
||||
renderComposerTag?: ComposerTagRenderer;
|
||||
renderComposerTagTooltip?: ComposerTagRenderer;
|
||||
onComposerTagClick?: ComposerTagClickHandler;
|
||||
renderAssistantTurnFooter?: AssistantTurnFooterRenderer;
|
||||
renderComposerToolbarStart?: ComposerToolbarStartRenderer;
|
||||
renderComposerToolbarEnd?: ComposerToolbarEndRenderer;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { act } from 'react';
|
|||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { EditorState, StateEffect } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { WebShellAtProvider, WebShellComposerTag } from '../customization';
|
||||
import type {
|
||||
WebShellAtProvider,
|
||||
WebShellBuiltinAtProvidersConfig,
|
||||
WebShellComposerTag,
|
||||
} from '../customization';
|
||||
import {
|
||||
useAtMentionMenu,
|
||||
type AtMentionWorkspaceActions,
|
||||
|
|
@ -44,6 +48,7 @@ function setViewState(view: EditorView, doc: string, anchor = doc.length) {
|
|||
function Harness({
|
||||
actions,
|
||||
disabled = false,
|
||||
builtinProviders,
|
||||
providers,
|
||||
shellMode = false,
|
||||
view,
|
||||
|
|
@ -51,6 +56,7 @@ function Harness({
|
|||
}: {
|
||||
actions?: AtMentionWorkspaceActions;
|
||||
disabled?: boolean;
|
||||
builtinProviders?: WebShellBuiltinAtProvidersConfig;
|
||||
providers?: readonly WebShellAtProvider[];
|
||||
shellMode?: boolean;
|
||||
view?: EditorView | null;
|
||||
|
|
@ -65,6 +71,7 @@ function Harness({
|
|||
disabledRef: { current: disabled },
|
||||
shellModeRef: { current: shellMode },
|
||||
workspaceActionsRef: { current: actions },
|
||||
builtinProviders,
|
||||
providers,
|
||||
createInlineTagEffect,
|
||||
});
|
||||
|
|
@ -74,6 +81,7 @@ function Harness({
|
|||
function mount({
|
||||
actions,
|
||||
disabled,
|
||||
builtinProviders,
|
||||
providers,
|
||||
shellMode,
|
||||
view,
|
||||
|
|
@ -81,6 +89,7 @@ function mount({
|
|||
}: {
|
||||
actions?: AtMentionWorkspaceActions;
|
||||
disabled?: boolean;
|
||||
builtinProviders?: WebShellBuiltinAtProvidersConfig;
|
||||
providers?: readonly WebShellAtProvider[];
|
||||
shellMode?: boolean;
|
||||
view?: EditorView | null;
|
||||
|
|
@ -98,6 +107,7 @@ function mount({
|
|||
<Harness
|
||||
actions={actions}
|
||||
disabled={disabled}
|
||||
builtinProviders={builtinProviders}
|
||||
providers={providers}
|
||||
shellMode={shellMode}
|
||||
view={view}
|
||||
|
|
@ -144,6 +154,104 @@ describe('useAtMentionMenu', () => {
|
|||
expect(latest!.state).toBeNull();
|
||||
});
|
||||
|
||||
it('can disable all built-in @ providers', () => {
|
||||
mount({ builtinProviders: false });
|
||||
|
||||
act(() => latest!.refreshForView(makeView('@')));
|
||||
|
||||
expect(latest!.state?.providers).toEqual([]);
|
||||
});
|
||||
|
||||
it('can whitelist built-in @ providers', () => {
|
||||
mount({ builtinProviders: ['files'] });
|
||||
|
||||
act(() => latest!.refreshForView(makeView('@')));
|
||||
|
||||
expect(latest!.state?.providers.map((provider) => provider.id)).toEqual([
|
||||
'files',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects custom providers that reuse built-in ids', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mount({
|
||||
builtinProviders: ['files'],
|
||||
providers: [
|
||||
{
|
||||
id: 'extensions',
|
||||
label: 'Custom Extensions',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => latest!.refreshForView(makeView('@')));
|
||||
|
||||
expect(latest!.state?.providers.map((provider) => provider.id)).toEqual([
|
||||
'files',
|
||||
]);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'[@mention] duplicate provider id="extensions" ignored',
|
||||
);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects custom providers that reuse disabled built-in ids', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mount({
|
||||
builtinProviders: false,
|
||||
providers: [
|
||||
{
|
||||
id: 'files',
|
||||
label: 'Custom Files',
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => latest!.refreshForView(makeView('@')));
|
||||
|
||||
expect(latest!.state?.providers).toEqual([]);
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'[@mention] duplicate provider id="files" ignored',
|
||||
);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects duplicate custom provider ids', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
mount({
|
||||
providers: [
|
||||
{
|
||||
id: 'custom',
|
||||
label: 'First',
|
||||
order: 0,
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
{
|
||||
id: 'custom',
|
||||
label: 'Second',
|
||||
order: 1,
|
||||
search: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => latest!.refreshForView(makeView('@')));
|
||||
|
||||
expect(latest!.state?.providers.map((provider) => provider.id)).toEqual([
|
||||
'custom',
|
||||
'files',
|
||||
'extensions',
|
||||
'mcp-resources',
|
||||
]);
|
||||
expect(latest!.state?.providers[0]?.label).toBe('First');
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'[@mention] duplicate provider id="custom" ignored',
|
||||
);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('strips ANSI, BiDi, and control characters from extension display text', async () => {
|
||||
vi.useFakeTimers();
|
||||
mount({
|
||||
|
|
@ -763,6 +871,7 @@ describe('useAtMentionMenu', () => {
|
|||
});
|
||||
|
||||
it('keeps built-in providers when custom provider ids collide', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const search = vi.fn().mockResolvedValue([]);
|
||||
mount({
|
||||
providers: [
|
||||
|
|
@ -781,6 +890,11 @@ describe('useAtMentionMenu', () => {
|
|||
'extensions',
|
||||
'mcp-resources',
|
||||
]);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
'[@mention] duplicate provider id="files" ignored',
|
||||
);
|
||||
error.mockRestore();
|
||||
});
|
||||
|
||||
it('accepts a custom item by inserting its label fallback', async () => {
|
||||
|
|
@ -877,6 +991,66 @@ describe('useAtMentionMenu', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('adds a separator after custom composer tag insert text', async () => {
|
||||
vi.useFakeTimers();
|
||||
const inlineTagEffect = StateEffect.define<{
|
||||
from: number;
|
||||
to: number;
|
||||
tag: WebShellComposerTag;
|
||||
}>();
|
||||
const view = makeView('@');
|
||||
mount({
|
||||
view,
|
||||
createInlineTagEffect: (range) => inlineTagEffect.of(range),
|
||||
providers: [
|
||||
{
|
||||
id: 'custom',
|
||||
label: 'Custom',
|
||||
order: 0,
|
||||
search: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'ctx-1',
|
||||
label: 'ctx-1',
|
||||
insertText: '<host_context_ref id="ctx-1">',
|
||||
composerTag: {
|
||||
id: 'ctx-1',
|
||||
kind: 'table',
|
||||
value: 'ctx-1',
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => latest!.refreshForView(view));
|
||||
act(() => latest!.enterCategory(0));
|
||||
await runDebounce();
|
||||
act(() => {
|
||||
expect(latest!.accept()).toBe(true);
|
||||
});
|
||||
|
||||
const spec = vi.mocked(view.dispatch).mock.calls[0]?.[0];
|
||||
expect(spec).toMatchObject({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: 1,
|
||||
insert: '<host_context_ref id="ctx-1"> ',
|
||||
},
|
||||
selection: { anchor: 30 },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
const effect = Array.isArray(spec?.effects) ? spec.effects[0] : undefined;
|
||||
expect(effect?.value).toMatchObject({
|
||||
from: 0,
|
||||
to: 29,
|
||||
tag: {
|
||||
id: 'ctx-1',
|
||||
serialized: '<host_context_ref id="ctx-1">',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes custom item label fallbacks before insertion', async () => {
|
||||
vi.useFakeTimers();
|
||||
const view = makeView('@');
|
||||
|
|
@ -1308,6 +1482,7 @@ describe('useAtMentionMenu', () => {
|
|||
|
||||
it('sanitizes custom provider item display text', async () => {
|
||||
vi.useFakeTimers();
|
||||
const dataIcon = 'data:image/png;base64,a\nb';
|
||||
mount({
|
||||
providers: [
|
||||
{
|
||||
|
|
@ -1320,6 +1495,12 @@ describe('useAtMentionMenu', () => {
|
|||
label: '\u001b[31mName\u001b[0m\u202E',
|
||||
description: 'Desc\u202E',
|
||||
detail: 'Detail\u202E',
|
||||
icon: dataIcon,
|
||||
iconTooltip: 'Tip\u202E',
|
||||
composerTag: {
|
||||
id: 'tag',
|
||||
icon: dataIcon,
|
||||
},
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
|
@ -1334,6 +1515,12 @@ describe('useAtMentionMenu', () => {
|
|||
label: 'Name',
|
||||
description: 'Desc',
|
||||
detail: 'Detail',
|
||||
icon: dataIcon,
|
||||
iconTooltip: 'Tip',
|
||||
composerTag: {
|
||||
id: 'tag',
|
||||
icon: dataIcon,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1482,6 +1669,63 @@ describe('useAtMentionMenu', () => {
|
|||
expect(latest!.state?.selectedIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('selects enabled provider tabs and ignores disabled or same-tab choices', async () => {
|
||||
vi.useFakeTimers();
|
||||
const search = vi.fn(({ tabId }) =>
|
||||
Promise.resolve([{ id: tabId ?? 'none', label: tabId ?? 'none' }]),
|
||||
);
|
||||
mount({
|
||||
providers: [
|
||||
{
|
||||
id: 'custom',
|
||||
label: 'Custom',
|
||||
order: 0,
|
||||
tabs: [
|
||||
{ id: 'open', label: 'Open' },
|
||||
{ id: 'disabled', label: 'Disabled', disabled: true },
|
||||
{ id: 'all', label: 'All' },
|
||||
],
|
||||
search,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
act(() => latest!.refreshForView(makeView('@')));
|
||||
act(() => latest!.enterCategory(0));
|
||||
await runDebounce();
|
||||
|
||||
expect(search).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ query: '', tabId: 'open' }),
|
||||
);
|
||||
expect(latest!.state).toMatchObject({
|
||||
selectedTabId: 'open',
|
||||
items: [expect.objectContaining({ id: 'open' })],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
expect(latest!.selectTab('disabled')).toBe(false);
|
||||
expect(latest!.selectTab('open')).toBe(true);
|
||||
});
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
expect(latest!.state?.selectedTabId).toBe('open');
|
||||
|
||||
act(() => {
|
||||
expect(latest!.selectTab('all')).toBe(true);
|
||||
});
|
||||
expect(latest!.state).toMatchObject({
|
||||
selectedTabId: 'all',
|
||||
loading: true,
|
||||
});
|
||||
await runDebounce();
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2);
|
||||
expect(search).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ query: '', tabId: 'all' }),
|
||||
);
|
||||
expect(latest!.state?.items[0]?.id).toBe('all');
|
||||
});
|
||||
|
||||
it('prefers the first matching file over the current-directory item', async () => {
|
||||
vi.useFakeTimers();
|
||||
const view = makeView('@');
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SetStateAction } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { RefObject, ReactNode } from 'react';
|
||||
import type { StateEffect } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type {
|
||||
WebShellAtItem,
|
||||
WebShellAtProvider,
|
||||
WebShellAtProviderTab,
|
||||
WebShellBuiltinAtProviderId,
|
||||
WebShellBuiltinAtProvidersConfig,
|
||||
WebShellComposerTag,
|
||||
} from '../customization';
|
||||
import { useI18n } from '../i18n';
|
||||
|
||||
export interface AtMentionProviderView {
|
||||
id: string;
|
||||
label: string;
|
||||
provider: WebShellAtProvider;
|
||||
label: ReactNode;
|
||||
textValue: string;
|
||||
description?: string;
|
||||
tabs?: readonly WebShellAtProviderTab[];
|
||||
selectedTabId?: string;
|
||||
renderItem?: WebShellAtProvider['renderItem'];
|
||||
}
|
||||
|
||||
export interface AtMentionItem extends WebShellAtItem {
|
||||
|
|
@ -38,6 +46,8 @@ export interface AtMentionMenuState {
|
|||
// search mode owns the panel input; context mode mirrors text typed in the editor.
|
||||
inputMode?: 'search' | 'context';
|
||||
validateMcpServer?: boolean;
|
||||
tabs?: readonly WebShellAtProviderTab[];
|
||||
selectedTabId?: string;
|
||||
}
|
||||
|
||||
type GlobWorkspaceFn = (
|
||||
|
|
@ -154,6 +164,7 @@ export interface UseAtMentionMenuOptions {
|
|||
disabledRef: RefObject<boolean>;
|
||||
shellModeRef: RefObject<boolean>;
|
||||
workspaceActionsRef: RefObject<AtMentionWorkspaceActions | undefined>;
|
||||
builtinProviders?: WebShellBuiltinAtProvidersConfig;
|
||||
providers?: readonly WebShellAtProvider[];
|
||||
createInlineTagEffect?: (range: {
|
||||
from: number;
|
||||
|
|
@ -170,6 +181,11 @@ const FILE_ROOT_ITEM_LIMIT = ITEM_LIMIT + 1;
|
|||
export const FILE_PROVIDER_ID = 'files';
|
||||
const EXTENSIONS_PROVIDER_ID = 'extensions';
|
||||
export const MCP_RESOURCES_PROVIDER_ID = 'mcp-resources';
|
||||
const BUILTIN_PROVIDER_IDS: readonly WebShellBuiltinAtProviderId[] = [
|
||||
FILE_PROVIDER_ID,
|
||||
EXTENSIONS_PROVIDER_ID,
|
||||
MCP_RESOURCES_PROVIDER_ID,
|
||||
];
|
||||
const ESC = String.fromCharCode(27);
|
||||
const ANSI_RE = new RegExp(`${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`, 'g');
|
||||
// Strip zero-width and BiDi controls so provider text cannot spoof paths/URIs.
|
||||
|
|
@ -177,10 +193,60 @@ const BIDI_CONTROL_RE = /[\u200B\u200E\u200F\u061C\u2066-\u2069\u202A-\u202E]/g;
|
|||
const SAFE_DISPLAY_FALLBACK = '[invalid]';
|
||||
const AT_REFERENCE_UNSAFE_CHARS = /[^\p{L}\p{N}_./-]/gu;
|
||||
function isBuiltinProviderId(providerId: string): boolean {
|
||||
return BUILTIN_PROVIDER_IDS.includes(
|
||||
providerId as WebShellBuiltinAtProviderId,
|
||||
);
|
||||
}
|
||||
|
||||
function isBuiltinProviderEnabled(
|
||||
providerId: WebShellBuiltinAtProviderId,
|
||||
config: WebShellBuiltinAtProvidersConfig | undefined,
|
||||
): boolean {
|
||||
if (config === undefined || config === true) return true;
|
||||
if (config === false) return false;
|
||||
if (Array.isArray(config)) {
|
||||
return (config as readonly WebShellBuiltinAtProviderId[]).includes(
|
||||
providerId,
|
||||
);
|
||||
}
|
||||
const options = config as Exclude<
|
||||
WebShellBuiltinAtProvidersConfig,
|
||||
boolean | readonly WebShellBuiltinAtProviderId[]
|
||||
>;
|
||||
if (options.enabled === false) return false;
|
||||
if (options.include && !options.include.includes(providerId)) return false;
|
||||
if (options.exclude?.includes(providerId)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getRegisteredCustomProviders(
|
||||
customProviders: readonly WebShellAtProvider[],
|
||||
): WebShellAtProvider[] {
|
||||
const registeredIds = new Set<string>(BUILTIN_PROVIDER_IDS);
|
||||
const accepted: WebShellAtProvider[] = [];
|
||||
for (const provider of customProviders) {
|
||||
if (registeredIds.has(provider.id)) {
|
||||
console.error(
|
||||
`[@mention] duplicate provider id="${provider.id}" ignored`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
registeredIds.add(provider.id);
|
||||
accepted.push(provider);
|
||||
}
|
||||
return accepted;
|
||||
}
|
||||
|
||||
function getProviderTextValue(provider: WebShellAtProvider): string {
|
||||
return (
|
||||
providerId === FILE_PROVIDER_ID ||
|
||||
providerId === EXTENSIONS_PROVIDER_ID ||
|
||||
providerId === MCP_RESOURCES_PROVIDER_ID
|
||||
(provider.textValue === undefined
|
||||
? undefined
|
||||
: sanitizeDisplayText(provider.textValue)) ??
|
||||
(typeof provider.label === 'string'
|
||||
? (sanitizeDisplayText(provider.label) ?? undefined)
|
||||
: undefined) ??
|
||||
safeDisplayText(provider.id) ??
|
||||
SAFE_DISPLAY_FALLBACK
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -429,6 +495,8 @@ function sanitizeComposerTag(
|
|||
value: tag.value === undefined ? undefined : sanitizeDisplayText(tag.value),
|
||||
removable: tag.removable,
|
||||
kind,
|
||||
icon: tag.icon,
|
||||
metadata: tag.metadata,
|
||||
serialized: sanitizeOptionalInsertText(tag.serialized),
|
||||
};
|
||||
}
|
||||
|
|
@ -444,8 +512,17 @@ function sanitizeAtMentionItem(
|
|||
item.description === undefined
|
||||
? undefined
|
||||
: sanitizeDisplayText(item.description),
|
||||
subtitle:
|
||||
item.subtitle === undefined
|
||||
? undefined
|
||||
: sanitizeDisplayText(item.subtitle),
|
||||
detail:
|
||||
item.detail === undefined ? undefined : sanitizeDisplayText(item.detail),
|
||||
icon: item.icon,
|
||||
iconTooltip:
|
||||
item.iconTooltip === undefined
|
||||
? undefined
|
||||
: sanitizeDisplayText(item.iconTooltip),
|
||||
insertText:
|
||||
item.insertText === undefined
|
||||
? undefined
|
||||
|
|
@ -758,6 +835,7 @@ export function useAtMentionMenu({
|
|||
disabledRef,
|
||||
shellModeRef,
|
||||
workspaceActionsRef,
|
||||
builtinProviders,
|
||||
providers = EMPTY_PROVIDERS,
|
||||
createInlineTagEffect,
|
||||
}: UseAtMentionMenuOptions) {
|
||||
|
|
@ -776,7 +854,7 @@ export function useAtMentionMenu({
|
|||
const preserveProviderSelectionRef = useRef(false);
|
||||
|
||||
const allProviders = useMemo(() => {
|
||||
const builtinProviders = [
|
||||
const builtinAtProviders = [
|
||||
createFileProvider(
|
||||
() => workspaceActionsRef.current,
|
||||
() => fileDirectoryRef.current,
|
||||
|
|
@ -797,15 +875,17 @@ export function useAtMentionMenu({
|
|||
t('at.category.mcpResources.description'),
|
||||
(count) => t('mcp.resourceCount', { count }),
|
||||
),
|
||||
];
|
||||
const builtinProviderIds = new Set(
|
||||
builtinProviders.map((provider) => provider.id),
|
||||
].filter((provider) =>
|
||||
isBuiltinProviderEnabled(
|
||||
provider.id as WebShellBuiltinAtProviderId,
|
||||
builtinProviders,
|
||||
),
|
||||
);
|
||||
return [
|
||||
...builtinProviders,
|
||||
...providers.filter((provider) => !builtinProviderIds.has(provider.id)),
|
||||
...builtinAtProviders,
|
||||
...getRegisteredCustomProviders(providers),
|
||||
].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
}, [providers, t, workspaceActionsRef]);
|
||||
}, [builtinProviders, providers, t, workspaceActionsRef]);
|
||||
const allProvidersRef = useRef(allProviders);
|
||||
allProvidersRef.current = allProviders;
|
||||
|
||||
|
|
@ -813,12 +893,18 @@ export function useAtMentionMenu({
|
|||
() =>
|
||||
allProviders.map((provider) => ({
|
||||
id: provider.id,
|
||||
provider,
|
||||
textValue: getProviderTextValue(provider),
|
||||
label:
|
||||
sanitizeDisplayText(provider.label) ?? safeDisplayText(provider.id),
|
||||
typeof provider.label === 'string'
|
||||
? getProviderTextValue(provider)
|
||||
: provider.label,
|
||||
description:
|
||||
provider.description === undefined
|
||||
? undefined
|
||||
: sanitizeDisplayText(provider.description),
|
||||
tabs: provider.tabs,
|
||||
renderItem: provider.renderItem,
|
||||
})),
|
||||
[allProviders],
|
||||
);
|
||||
|
|
@ -878,6 +964,8 @@ export function useAtMentionMenu({
|
|||
mcpServerName: undefined,
|
||||
fileDirectory: undefined,
|
||||
inputMode: undefined,
|
||||
tabs: undefined,
|
||||
selectedTabId: undefined,
|
||||
});
|
||||
return 'categories';
|
||||
}
|
||||
|
|
@ -908,6 +996,7 @@ export function useAtMentionMenu({
|
|||
if (
|
||||
current?.level !== 'items' ||
|
||||
current.selectedProviderId !== providerId ||
|
||||
current.selectedTabId !== baseState.selectedTabId ||
|
||||
current.itemMode !== baseState.itemMode ||
|
||||
current.mcpServerName !== baseState.mcpServerName ||
|
||||
current.fileDirectory !== baseState.fileDirectory
|
||||
|
|
@ -988,7 +1077,13 @@ export function useAtMentionMenu({
|
|||
setMenu({ ...baseState, items: previousItems, loading: true });
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => provider.search({ query, signal: abort.signal }))
|
||||
.then(() =>
|
||||
provider.search({
|
||||
query,
|
||||
signal: abort.signal,
|
||||
tabId: baseState.selectedTabId,
|
||||
}),
|
||||
)
|
||||
.then((items) => {
|
||||
if (abort.signal.aborted || requestIdRef.current !== requestId) {
|
||||
return;
|
||||
|
|
@ -1293,7 +1388,11 @@ export function useAtMentionMenu({
|
|||
return true;
|
||||
}
|
||||
const filteredProviders = providerViewsRef.current.filter((provider) => {
|
||||
return matchesQuery(parsed.query, provider.label, provider.description);
|
||||
return matchesQuery(
|
||||
parsed.query,
|
||||
provider.textValue,
|
||||
provider.description,
|
||||
);
|
||||
});
|
||||
if (filteredProviders.length === 0 && parsed.query) {
|
||||
const insertedReference = splitInsertedReferenceQuery(
|
||||
|
|
@ -1470,11 +1569,14 @@ export function useAtMentionMenu({
|
|||
if (provider.id === FILE_PROVIDER_ID) {
|
||||
fileDirectoryRef.current = '.';
|
||||
}
|
||||
const selectedTabId = provider.tabs?.find((tab) => !tab.disabled)?.id;
|
||||
scheduleLoadItems(provider.id, current.query, {
|
||||
...current,
|
||||
level: 'items',
|
||||
selectedProviderId: provider.id,
|
||||
selectedIndex: 0,
|
||||
tabs: provider.tabs,
|
||||
selectedTabId,
|
||||
itemMode:
|
||||
provider.id === MCP_RESOURCES_PROVIDER_ID ? 'mcpServers' : 'default',
|
||||
mcpServerName: undefined,
|
||||
|
|
@ -1521,6 +1623,30 @@ export function useAtMentionMenu({
|
|||
[scheduleLoadItems, scheduleLoadMcpResourceItems],
|
||||
);
|
||||
|
||||
const selectTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const current = stateRef.current;
|
||||
if (
|
||||
!current ||
|
||||
current.level !== 'items' ||
|
||||
!current.selectedProviderId ||
|
||||
!current.tabs?.some((tab) => tab.id === tabId && !tab.disabled)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (current.selectedTabId === tabId) return true;
|
||||
const baseState: Omit<AtMentionMenuState, 'items' | 'loading'> = {
|
||||
...current,
|
||||
selectedTabId: tabId,
|
||||
selectedIndex: 0,
|
||||
inputMode: 'search',
|
||||
};
|
||||
scheduleLoadItems(current.selectedProviderId, current.query, baseState);
|
||||
return true;
|
||||
},
|
||||
[scheduleLoadItems],
|
||||
);
|
||||
|
||||
const backToCategories = useCallback((): false | 'items' | 'categories' => {
|
||||
const current = stateRef.current;
|
||||
if (!current || current.level !== 'items') return false;
|
||||
|
|
@ -1568,6 +1694,8 @@ export function useAtMentionMenu({
|
|||
mcpServerName: undefined,
|
||||
fileDirectory: undefined,
|
||||
inputMode: undefined,
|
||||
tabs: undefined,
|
||||
selectedTabId: undefined,
|
||||
});
|
||||
clearPendingLoad();
|
||||
return 'categories';
|
||||
|
|
@ -1621,9 +1749,13 @@ export function useAtMentionMenu({
|
|||
});
|
||||
return true;
|
||||
}
|
||||
const insert =
|
||||
const rawInsert =
|
||||
item.insertText ??
|
||||
`@${escapeAtReferenceText(sanitizeInsertText(item.label))} `;
|
||||
const insert =
|
||||
item.composerTag && !/\s$/.test(rawInsert)
|
||||
? `${rawInsert} `
|
||||
: rawInsert;
|
||||
const docLength = view.state.doc.length;
|
||||
if (
|
||||
current.from < 0 ||
|
||||
|
|
@ -1696,6 +1828,7 @@ export function useAtMentionMenu({
|
|||
select,
|
||||
accept,
|
||||
enterCategory,
|
||||
selectTab,
|
||||
backToCategories,
|
||||
updateSearch,
|
||||
};
|
||||
|
|
|
|||
165
packages/web-shell/client/hooks/useComposerCore.dom.test.tsx
Normal file
165
packages/web-shell/client/hooks/useComposerCore.dom.test.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, type ReactNode } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { I18nProvider } from '../i18n';
|
||||
import { useComposerCore, type UseComposerCoreReturn } from './useComposerCore';
|
||||
import type { WebShellComposerInput } from '../customization';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
let latest: UseComposerCoreReturn | null = null;
|
||||
|
||||
function Harness({
|
||||
composerInput,
|
||||
onSubmit,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
}: {
|
||||
composerInput?: WebShellComposerInput;
|
||||
onSubmit: ReturnType<typeof vi.fn>;
|
||||
renderComposerTag?: () => ReactNode;
|
||||
renderComposerTagTooltip?: () => ReactNode;
|
||||
}) {
|
||||
const composer = useComposerCore({
|
||||
onSubmit,
|
||||
commands: [],
|
||||
editorTheme: {},
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
composerInput,
|
||||
composerInputVersion: composerInput ? 1 : undefined,
|
||||
});
|
||||
latest = composer;
|
||||
|
||||
return <div ref={composer.containerRef} />;
|
||||
}
|
||||
|
||||
async function mount({
|
||||
composerInput,
|
||||
onSubmit = vi.fn(),
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
}: {
|
||||
composerInput?: WebShellComposerInput;
|
||||
onSubmit?: ReturnType<typeof vi.fn>;
|
||||
renderComposerTag?: () => ReactNode;
|
||||
renderComposerTagTooltip?: () => ReactNode;
|
||||
} = {}) {
|
||||
container = document.createElement('div');
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root!.render(
|
||||
<I18nProvider language="en">
|
||||
<Harness
|
||||
composerInput={composerInput}
|
||||
onSubmit={onSubmit}
|
||||
renderComposerTag={renderComposerTag}
|
||||
renderComposerTagTooltip={renderComposerTagTooltip}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
return { onSubmit };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
latest = null;
|
||||
});
|
||||
|
||||
describe('useComposerCore inline tags', () => {
|
||||
it('falls back when inline custom tag rendering throws', async () => {
|
||||
const error = new Error('boom');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await mount({
|
||||
composerInput: {
|
||||
tags: [{ id: 'orders', label: 'Table', value: 'orders' }],
|
||||
tagPlacement: 'inline',
|
||||
},
|
||||
renderComposerTag: () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[WebShell] inline tag renderContent failed',
|
||||
error,
|
||||
);
|
||||
expect(document.body.textContent).toContain('orders');
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('falls back when inline custom tag tooltip rendering throws', async () => {
|
||||
const error = new Error('bad tooltip');
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await mount({
|
||||
composerInput: {
|
||||
tags: [{ id: 'orders', label: 'Table', value: 'orders' }],
|
||||
tagPlacement: 'inline',
|
||||
},
|
||||
renderComposerTagTooltip: () => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[WebShell] inline tag tooltip render failed',
|
||||
error,
|
||||
);
|
||||
expect(document.body.textContent).toContain('orders');
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('guards inline mask icon sources', async () => {
|
||||
await mount({
|
||||
composerInput: {
|
||||
tags: [
|
||||
{
|
||||
id: 'orders',
|
||||
label: 'Table',
|
||||
value: 'orders',
|
||||
icon: 'javascript:alert(1)',
|
||||
},
|
||||
],
|
||||
tagPlacement: 'inline',
|
||||
},
|
||||
});
|
||||
|
||||
expect(document.body.innerHTML).not.toContain('javascript:alert');
|
||||
expect(
|
||||
document.body.querySelector('[style*="--composer-tag-icon-url"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps inline tags after trimming leading whitespace on submit', async () => {
|
||||
const { onSubmit } = await mount();
|
||||
|
||||
act(() => {
|
||||
latest!.setText(' ');
|
||||
latest!.addTags(
|
||||
[{ id: 'orders', value: 'orders', serialized: '<table />' }],
|
||||
{ placement: 'inline' },
|
||||
);
|
||||
latest!.insertText('explain');
|
||||
latest!.submitText();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
'<table /> explain',
|
||||
undefined,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildComposerPrompt,
|
||||
buildComposerPromptWithInlineTagPlacements,
|
||||
createLargePastePlaceholder,
|
||||
expandLargePastePlaceholders,
|
||||
getComposerTagDisplay,
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
isLargePaste,
|
||||
normalizePastedText,
|
||||
prunePendingPastes,
|
||||
replaceInlineTagPlacements,
|
||||
serializeComposerTag,
|
||||
} from './useComposerCore';
|
||||
|
||||
|
|
@ -141,4 +143,74 @@ describe('composer tag serialization', () => {
|
|||
);
|
||||
expect(buildComposerPrompt('do it', [])).toBe('do it');
|
||||
});
|
||||
|
||||
it('keeps inline tags at their editor positions', () => {
|
||||
expect(
|
||||
buildComposerPromptWithInlineTagPlacements(
|
||||
'explain @orders now',
|
||||
[{ id: 'top', serialized: '<top />' }],
|
||||
[
|
||||
{
|
||||
start: 8,
|
||||
end: 15,
|
||||
tag: { id: 'table', value: 'orders', serialized: '<table />' },
|
||||
},
|
||||
],
|
||||
),
|
||||
).toBe('<top />\n\nexplain <table /> now');
|
||||
});
|
||||
|
||||
it('ignores invalid and overlapping inline tag placements', () => {
|
||||
expect(
|
||||
replaceInlineTagPlacements('a @one and @two', [
|
||||
{
|
||||
start: -1,
|
||||
end: 2,
|
||||
tag: { id: 'negative', serialized: '<negative />' },
|
||||
},
|
||||
{
|
||||
start: 2,
|
||||
end: 6,
|
||||
tag: { id: 'one', serialized: '<one />' },
|
||||
},
|
||||
{
|
||||
start: 4,
|
||||
end: 12,
|
||||
tag: { id: 'overlap', serialized: '<overlap />' },
|
||||
},
|
||||
{
|
||||
start: 11,
|
||||
end: 15,
|
||||
tag: { id: 'two', serialized: '<two />' },
|
||||
},
|
||||
{
|
||||
start: 14,
|
||||
end: 99,
|
||||
tag: { id: 'beyond', serialized: '<beyond />' },
|
||||
},
|
||||
]),
|
||||
).toBe('a <one /> and <two />');
|
||||
});
|
||||
|
||||
it('replaces inline tags before expanding large paste placeholders', () => {
|
||||
const pending = new Map<string, string>();
|
||||
const paste = createLargePastePlaceholder(
|
||||
pending,
|
||||
1,
|
||||
'expanded pasted content that is longer than the placeholder',
|
||||
);
|
||||
const text = `${paste.placeholderText} explain @orders`;
|
||||
const tagStart = text.indexOf('@orders');
|
||||
const withInlineTags = replaceInlineTagPlacements(text, [
|
||||
{
|
||||
start: tagStart,
|
||||
end: tagStart + '@orders'.length,
|
||||
tag: { id: 'table', value: 'orders', serialized: '<table />' },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(expandLargePastePlaceholders(pending, withInlineTags)).toBe(
|
||||
'expanded pasted content that is longer than the placeholder explain <table />',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
|
|
@ -54,13 +61,17 @@ import {
|
|||
import { isEditableTarget } from '../utils/dom';
|
||||
import { cssUrlValue } from '../utils/cssUrlVar';
|
||||
import { getComposerTagIconUrl } from '../components/composerTagIcons';
|
||||
import { isSafeImageSrc } from '../components/messages/Markdown';
|
||||
import type {
|
||||
ComposerTagClickHandler,
|
||||
ComposerTagRenderer,
|
||||
WebShellComposerApi,
|
||||
WebShellComposerInput,
|
||||
WebShellComposerTag,
|
||||
WebShellComposerTagIconMap,
|
||||
WebShellComposerTagOptions,
|
||||
WebShellComposerTextOptions,
|
||||
WebShellBuiltinAtProvidersConfig,
|
||||
WebShellAtProvider,
|
||||
} from '../customization';
|
||||
|
||||
|
|
@ -508,6 +519,50 @@ export function buildComposerPrompt(
|
|||
return `${tagText}\n\n${text}`;
|
||||
}
|
||||
|
||||
export interface InlineTagPlacement {
|
||||
start: number;
|
||||
end: number;
|
||||
tag: WebShellComposerTag;
|
||||
}
|
||||
|
||||
export function buildComposerPromptWithInlineTagPlacements(
|
||||
text: string,
|
||||
topTags: readonly WebShellComposerTag[],
|
||||
inlineTags: readonly InlineTagPlacement[],
|
||||
): string {
|
||||
return buildComposerPrompt(
|
||||
replaceInlineTagPlacements(text, inlineTags),
|
||||
topTags,
|
||||
);
|
||||
}
|
||||
|
||||
export function replaceInlineTagPlacements(
|
||||
text: string,
|
||||
inlineTags: readonly InlineTagPlacement[],
|
||||
): string {
|
||||
const placements = inlineTags
|
||||
.filter(
|
||||
(placement) =>
|
||||
placement.start >= 0 &&
|
||||
placement.end > placement.start &&
|
||||
placement.end <= text.length,
|
||||
)
|
||||
.slice()
|
||||
.sort((left, right) => left.start - right.start);
|
||||
if (placements.length === 0) return text;
|
||||
|
||||
let cursor = 0;
|
||||
const parts: string[] = [];
|
||||
for (const placement of placements) {
|
||||
if (placement.start < cursor) continue;
|
||||
parts.push(text.slice(cursor, placement.start));
|
||||
parts.push(serializeComposerTag(placement.tag));
|
||||
cursor = placement.end;
|
||||
}
|
||||
parts.push(text.slice(cursor));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
// ---- Inline tag CodeMirror extension (shared) ----
|
||||
|
||||
interface InlineTagRange {
|
||||
|
|
@ -520,11 +575,21 @@ interface InlineTagDecorationSpec {
|
|||
tag: InlineComposerTag;
|
||||
}
|
||||
|
||||
type InlineComposerTag = WebShellComposerTag & { iconUrl?: string };
|
||||
type InlineComposerTag = WebShellComposerTag & {
|
||||
iconUrl?: string;
|
||||
renderContent?: ComposerTagRenderer;
|
||||
tooltip?: ReactNode;
|
||||
tooltipText?: string;
|
||||
onClick?: ComposerTagClickHandler;
|
||||
};
|
||||
|
||||
function toPublicComposerTag(tag: InlineComposerTag): WebShellComposerTag {
|
||||
const publicTag = { ...tag };
|
||||
delete publicTag.iconUrl;
|
||||
delete publicTag.renderContent;
|
||||
delete publicTag.tooltip;
|
||||
delete publicTag.tooltipText;
|
||||
delete publicTag.onClick;
|
||||
return publicTag;
|
||||
}
|
||||
|
||||
|
|
@ -537,6 +602,9 @@ export const removeInlineTagEffect = StateEffect.define<{
|
|||
export const clearInlineTagsEffect = StateEffect.define<void>();
|
||||
|
||||
class ComposerTagWidget extends WidgetType {
|
||||
private contentRoot: Root | null = null;
|
||||
private tooltipRoot: Root | null = null;
|
||||
|
||||
constructor(private readonly tag: InlineComposerTag) {
|
||||
super();
|
||||
}
|
||||
|
|
@ -547,29 +615,120 @@ class ComposerTagWidget extends WidgetType {
|
|||
this.tag.label === other.tag.label &&
|
||||
this.tag.value === other.tag.value &&
|
||||
this.tag.kind === other.tag.kind &&
|
||||
this.tag.icon === other.tag.icon &&
|
||||
this.tag.serialized === other.tag.serialized &&
|
||||
this.tag.removable === other.tag.removable &&
|
||||
this.tag.iconUrl === other.tag.iconUrl
|
||||
this.tag.iconUrl === other.tag.iconUrl &&
|
||||
this.tag.renderContent === other.tag.renderContent &&
|
||||
this.tag.tooltip === other.tag.tooltip &&
|
||||
this.tag.tooltipText === other.tag.tooltipText &&
|
||||
this.tag.onClick === other.tag.onClick
|
||||
);
|
||||
}
|
||||
|
||||
toDOM(view: EditorView): HTMLElement {
|
||||
const chip = document.createElement('span');
|
||||
const publicTag = toPublicComposerTag(this.tag);
|
||||
chip.style.cssText =
|
||||
'display:inline-flex;align-items:center;max-width:min(44ch,100%);min-height:20px;margin:0 0.25ch;border:1px solid var(--border);border-radius:4px;background:var(--secondary);color:var(--foreground);font-family:var(--font-mono,monospace);font-size:12px;line-height:1.2;vertical-align:baseline;';
|
||||
'position:relative;display:inline-flex;align-items:center;max-width:min(44ch,100%);min-height:20px;margin:0 0.25ch;border:1px solid var(--border);border-radius:4px;background:var(--secondary);color:var(--foreground);font-family:var(--font-mono,monospace);font-size:12px;line-height:1.2;vertical-align:baseline;';
|
||||
if (this.tag.tooltipText) chip.title = this.tag.tooltipText;
|
||||
if (this.tag.onClick) {
|
||||
chip.setAttribute('role', 'button');
|
||||
chip.tabIndex = 0;
|
||||
chip.style.cursor = 'pointer';
|
||||
chip.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
chip.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
});
|
||||
chip.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
this.tag.onClick?.({
|
||||
tag: publicTag,
|
||||
placement: 'composer',
|
||||
readonly: false,
|
||||
anchorRect: chip.getBoundingClientRect(),
|
||||
});
|
||||
});
|
||||
chip.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
this.tag.onClick?.({
|
||||
tag: publicTag,
|
||||
placement: 'composer',
|
||||
readonly: false,
|
||||
anchorRect: chip.getBoundingClientRect(),
|
||||
});
|
||||
});
|
||||
}
|
||||
const rawTagLabel = getComposerTagLabel(this.tag);
|
||||
const tagValue = getComposerTagValue(this.tag);
|
||||
const tagLabel = this.tag.kind ? '' : rawTagLabel;
|
||||
const iconUrl = this.tag.iconUrl ?? getComposerTagIconUrl(this.tag.kind);
|
||||
const safeIconUrl =
|
||||
iconUrl && isSafeImageSrc(iconUrl) ? iconUrl : undefined;
|
||||
let customContent: ReactNode | null | undefined;
|
||||
try {
|
||||
customContent = this.tag.renderContent?.({
|
||||
tag: publicTag,
|
||||
placement: 'composer',
|
||||
readonly: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[WebShell] inline tag renderContent failed', error);
|
||||
}
|
||||
|
||||
if (iconUrl) {
|
||||
let renderedCustomContent = false;
|
||||
if (customContent !== undefined && customContent !== null) {
|
||||
const content = document.createElement('span');
|
||||
content.style.cssText =
|
||||
'display:inline-flex;align-items:center;min-width:0;max-width:100%;';
|
||||
try {
|
||||
this.contentRoot = createRoot(content);
|
||||
this.contentRoot.render(customContent);
|
||||
chip.appendChild(content);
|
||||
renderedCustomContent = true;
|
||||
} catch (error) {
|
||||
this.contentRoot?.unmount();
|
||||
this.contentRoot = null;
|
||||
console.warn('[WebShell] inline tag renderContent failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!renderedCustomContent && safeIconUrl) {
|
||||
const icon = document.createElement('span');
|
||||
icon.style.cssText =
|
||||
'display:block;width:12px;height:12px;flex:0 0 auto;margin-left:7px;background:currentColor;mask:var(--composer-tag-icon-url) center / contain no-repeat;-webkit-mask:var(--composer-tag-icon-url) center / contain no-repeat;';
|
||||
icon.style.setProperty('--composer-tag-icon-url', cssUrlValue(iconUrl));
|
||||
icon.style.setProperty(
|
||||
'--composer-tag-icon-url',
|
||||
cssUrlValue(safeIconUrl),
|
||||
);
|
||||
chip.appendChild(icon);
|
||||
}
|
||||
|
||||
if (!renderedCustomContent) {
|
||||
this.appendDefaultContent(chip, tagLabel, tagValue);
|
||||
}
|
||||
|
||||
if (this.tag.tooltip !== undefined && this.tag.tooltip !== null) {
|
||||
this.appendTooltip(chip, this.tag.tooltip);
|
||||
}
|
||||
|
||||
if (this.tag.removable !== false) {
|
||||
this.appendRemoveButton(chip, view);
|
||||
}
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
private appendDefaultContent(
|
||||
chip: HTMLElement,
|
||||
tagLabel: string,
|
||||
tagValue: string,
|
||||
) {
|
||||
if (tagLabel) {
|
||||
const label = document.createElement('span');
|
||||
label.style.cssText =
|
||||
|
|
@ -591,49 +750,90 @@ class ComposerTagWidget extends WidgetType {
|
|||
fallback.textContent = this.tag.id;
|
||||
chip.appendChild(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.tag.removable !== false) {
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.setAttribute(
|
||||
'aria-label',
|
||||
`Remove ${getComposerTagDisplay(this.tag)}`,
|
||||
);
|
||||
remove.style.cssText =
|
||||
'flex:0 0 auto;width:22px;height:22px;padding:0;border:0;background:transparent;color:var(--muted-foreground);font:inherit;line-height:22px;cursor:pointer;';
|
||||
remove.textContent = '×';
|
||||
remove.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
remove.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const changes: Array<{ from: number; to: number; insert: string }> = [];
|
||||
view.state
|
||||
.field(inlineComposerTagField)
|
||||
.between(0, view.state.doc.length, (from, to, value) => {
|
||||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||||
if (tag?.id === this.tag.id && tag.removable !== false) {
|
||||
changes.push({ from, to, insert: '' });
|
||||
}
|
||||
});
|
||||
if (changes.length === 0) return;
|
||||
view.dispatch({
|
||||
changes,
|
||||
effects: removeInlineTagEffect.of({
|
||||
predicate: (tag) => tag.id === this.tag.id,
|
||||
}),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
});
|
||||
remove.addEventListener('mouseenter', () => {
|
||||
remove.style.color = 'var(--error-color)';
|
||||
});
|
||||
remove.addEventListener('mouseleave', () => {
|
||||
remove.style.color = 'var(--muted-foreground)';
|
||||
});
|
||||
chip.appendChild(remove);
|
||||
private appendTooltip(chip: HTMLElement, tooltip: ReactNode) {
|
||||
const tooltipElement = document.createElement('span');
|
||||
tooltipElement.setAttribute('role', 'tooltip');
|
||||
tooltipElement.style.cssText =
|
||||
'position:absolute;z-index:calc(var(--web-shell-tooltip-z-index,1000) + 1);top:calc(100% + 6px);left:0;display:none;min-width:160px;max-width:min(320px,80vw);padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--background);box-shadow:0 8px 24px rgba(0,0,0,0.18);color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:12px;line-height:1.5;white-space:normal;';
|
||||
const show = () => {
|
||||
tooltipElement.style.display = 'block';
|
||||
};
|
||||
const hide = () => {
|
||||
tooltipElement.style.display = 'none';
|
||||
};
|
||||
chip.addEventListener('mouseenter', show);
|
||||
chip.addEventListener('mouseleave', hide);
|
||||
chip.addEventListener('focusin', show);
|
||||
chip.addEventListener('focusout', hide);
|
||||
try {
|
||||
this.tooltipRoot = createRoot(tooltipElement);
|
||||
this.tooltipRoot.render(tooltip);
|
||||
chip.appendChild(tooltipElement);
|
||||
} catch (error) {
|
||||
this.tooltipRoot?.unmount();
|
||||
this.tooltipRoot = null;
|
||||
console.warn('[WebShell] inline tag tooltip render failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
return chip;
|
||||
private appendRemoveButton(chip: HTMLElement, view: EditorView) {
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.setAttribute(
|
||||
'aria-label',
|
||||
`Remove ${getComposerTagDisplay(this.tag)}`,
|
||||
);
|
||||
remove.style.cssText =
|
||||
'flex:0 0 auto;width:22px;height:22px;padding:0;border:0;background:transparent;color:var(--muted-foreground);font:inherit;line-height:22px;cursor:pointer;';
|
||||
remove.textContent = '×';
|
||||
remove.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
remove.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Backspace' && event.key !== 'Delete') return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
remove.click();
|
||||
});
|
||||
remove.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const changes: Array<{ from: number; to: number; insert: string }> = [];
|
||||
view.state
|
||||
.field(inlineComposerTagField)
|
||||
.between(0, view.state.doc.length, (from, to, value) => {
|
||||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||||
if (tag?.id === this.tag.id && tag.removable !== false) {
|
||||
changes.push({ from, to, insert: '' });
|
||||
}
|
||||
});
|
||||
if (changes.length === 0) return;
|
||||
view.dispatch({
|
||||
changes,
|
||||
effects: removeInlineTagEffect.of({
|
||||
predicate: (tag) => tag.id === this.tag.id,
|
||||
}),
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
});
|
||||
remove.addEventListener('mouseenter', () => {
|
||||
remove.style.color = 'var(--error-color)';
|
||||
});
|
||||
remove.addEventListener('mouseleave', () => {
|
||||
remove.style.color = 'var(--muted-foreground)';
|
||||
});
|
||||
chip.appendChild(remove);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.contentRoot?.unmount();
|
||||
this.tooltipRoot?.unmount();
|
||||
this.contentRoot = null;
|
||||
this.tooltipRoot = null;
|
||||
}
|
||||
|
||||
ignoreEvent(): boolean {
|
||||
|
|
@ -690,6 +890,25 @@ export function getInlineComposerTags(view: EditorView): WebShellComposerTag[] {
|
|||
return tags;
|
||||
}
|
||||
|
||||
function getInlineComposerTagPlacements(
|
||||
view: EditorView,
|
||||
): InlineTagPlacement[] {
|
||||
const placements: InlineTagPlacement[] = [];
|
||||
view.state
|
||||
.field(inlineComposerTagField)
|
||||
.between(0, view.state.doc.length, (from, to, value) => {
|
||||
const tag = (value.spec as Partial<InlineTagDecorationSpec>).tag;
|
||||
if (tag) {
|
||||
placements.push({
|
||||
start: from,
|
||||
end: to,
|
||||
tag: toPublicComposerTag(tag),
|
||||
});
|
||||
}
|
||||
});
|
||||
return placements;
|
||||
}
|
||||
|
||||
// ---- EditorHandle type (shared) ----
|
||||
|
||||
export interface EditorHandle extends WebShellComposerApi {
|
||||
|
|
@ -817,8 +1036,12 @@ export interface UseComposerCoreOptions {
|
|||
sessionName?: string;
|
||||
composerInput?: WebShellComposerInput;
|
||||
composerInputVersion?: number;
|
||||
builtinAtProviders?: WebShellBuiltinAtProvidersConfig;
|
||||
atProviders?: readonly WebShellAtProvider[];
|
||||
composerTagIcons?: WebShellComposerTagIconMap;
|
||||
renderComposerTag?: ComposerTagRenderer;
|
||||
renderComposerTagTooltip?: ComposerTagRenderer;
|
||||
onComposerTagClick?: ComposerTagClickHandler;
|
||||
/** CodeMirror theme extension for the editor view. Each variant provides its own. */
|
||||
editorTheme: Parameters<typeof EditorView.theme>[0];
|
||||
}
|
||||
|
|
@ -934,6 +1157,7 @@ export interface UseComposerCoreReturn {
|
|||
enterAtCategory: (index?: number) => boolean;
|
||||
backAtCategories: () => false | 'items' | 'categories';
|
||||
updateAtSearch: (query: string) => boolean;
|
||||
selectAtTab: (tabId: string) => boolean;
|
||||
}
|
||||
|
||||
export function useComposerCore(
|
||||
|
|
@ -959,8 +1183,12 @@ export function useComposerCore(
|
|||
sessionName,
|
||||
composerInput,
|
||||
composerInputVersion,
|
||||
builtinAtProviders,
|
||||
atProviders,
|
||||
composerTagIcons,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
onComposerTagClick,
|
||||
editorTheme,
|
||||
} = options;
|
||||
|
||||
|
|
@ -1002,13 +1230,44 @@ export function useComposerCore(
|
|||
workspaceActionsRef.current = workspace?.actions;
|
||||
const composerTagIconsRef = useRef(composerTagIcons);
|
||||
composerTagIconsRef.current = composerTagIcons;
|
||||
const renderComposerTagRef = useRef(renderComposerTag);
|
||||
renderComposerTagRef.current = renderComposerTag;
|
||||
const renderComposerTagTooltipRef = useRef(renderComposerTagTooltip);
|
||||
renderComposerTagTooltipRef.current = renderComposerTagTooltip;
|
||||
const onComposerTagClickRef = useRef(onComposerTagClick);
|
||||
onComposerTagClickRef.current = onComposerTagClick;
|
||||
const resolveComposerTagIcon = useCallback(
|
||||
(tag: WebShellComposerTag): InlineComposerTag => {
|
||||
const iconUrl = getComposerTagIconUrl(
|
||||
tag.kind,
|
||||
composerTagIconsRef.current,
|
||||
);
|
||||
return iconUrl ? { ...tag, iconUrl } : tag;
|
||||
const iconUrl =
|
||||
tag.icon ??
|
||||
getComposerTagIconUrl(tag.kind, composerTagIconsRef.current);
|
||||
const info = {
|
||||
tag,
|
||||
placement: 'composer' as const,
|
||||
readonly: false,
|
||||
};
|
||||
let tooltip: ReactNode | null | undefined;
|
||||
try {
|
||||
tooltip = renderComposerTagTooltipRef.current?.(info);
|
||||
} catch (error) {
|
||||
console.warn('[WebShell] inline tag tooltip render failed', error);
|
||||
}
|
||||
const tooltipText =
|
||||
typeof tooltip === 'string' || typeof tooltip === 'number'
|
||||
? String(tooltip)
|
||||
: undefined;
|
||||
return {
|
||||
...tag,
|
||||
...(iconUrl ? { iconUrl } : {}),
|
||||
...(renderComposerTagRef.current
|
||||
? { renderContent: renderComposerTagRef.current }
|
||||
: {}),
|
||||
...(tooltip !== undefined && tooltip !== null ? { tooltip } : {}),
|
||||
...(tooltipText ? { tooltipText } : {}),
|
||||
...(onComposerTagClickRef.current
|
||||
? { onClick: onComposerTagClickRef.current }
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -1020,6 +1279,7 @@ export function useComposerCore(
|
|||
disabledRef,
|
||||
shellModeRef,
|
||||
workspaceActionsRef,
|
||||
builtinProviders: builtinAtProviders,
|
||||
providers: atProviders,
|
||||
createInlineTagEffect: (range) =>
|
||||
addInlineTagEffect.of({
|
||||
|
|
@ -1049,7 +1309,13 @@ export function useComposerCore(
|
|||
});
|
||||
if (effects.length === 1) return;
|
||||
view.dispatch({ effects });
|
||||
}, [composerTagIcons, resolveComposerTagIcon]);
|
||||
}, [
|
||||
composerTagIcons,
|
||||
onComposerTagClick,
|
||||
renderComposerTag,
|
||||
renderComposerTagTooltip,
|
||||
resolveComposerTagIcon,
|
||||
]);
|
||||
|
||||
const toggleShellMode = useCallback(() => {
|
||||
if (followupStateRef.current?.isVisible) {
|
||||
|
|
@ -1437,18 +1703,43 @@ export function useComposerCore(
|
|||
textOverride?: string,
|
||||
tagsOverride?: readonly WebShellComposerTag[],
|
||||
) => {
|
||||
const inlineTags =
|
||||
tagsOverride === undefined ? getInlineComposerTagPlacements(view) : [];
|
||||
const editorText = view.state.doc.toString();
|
||||
const followup = followupStateRef.current;
|
||||
const followupCompletion =
|
||||
textOverride === undefined && followup?.isVisible
|
||||
textOverride === undefined &&
|
||||
inlineTags.length === 0 &&
|
||||
followup?.isVisible
|
||||
? getFollowupCompletion(editorText, followup.suggestion)
|
||||
: null;
|
||||
const rawText = (textOverride ?? followupCompletion ?? editorText).trim();
|
||||
const sourceText = textOverride ?? followupCompletion ?? editorText;
|
||||
const leadingTrimLength =
|
||||
sourceText.length - sourceText.trimStart().length;
|
||||
const rawText = sourceText.trim();
|
||||
const normalizedInlineTags =
|
||||
textOverride === undefined && followupCompletion === null
|
||||
? inlineTags
|
||||
.map((placement) => ({
|
||||
...placement,
|
||||
start: placement.start - leadingTrimLength,
|
||||
end: placement.end - leadingTrimLength,
|
||||
}))
|
||||
.filter((placement) => placement.end > 0)
|
||||
.map((placement) => ({
|
||||
...placement,
|
||||
start: Math.max(0, placement.start),
|
||||
}))
|
||||
: [];
|
||||
const tags = tagsOverride ?? composerTagsRef.current;
|
||||
if (!rawText && tags.length === 0) return true;
|
||||
const textWithInlineTags =
|
||||
tagsOverride === undefined
|
||||
? replaceInlineTagPlacements(rawText, normalizedInlineTags)
|
||||
: rawText;
|
||||
const text = expandLargePastePlaceholders(
|
||||
pendingPastesRef.current,
|
||||
rawText,
|
||||
textWithInlineTags,
|
||||
);
|
||||
const prompt = buildComposerPrompt(text, tags);
|
||||
const images = pastedImagesRef.current;
|
||||
|
|
@ -1574,10 +1865,13 @@ export function useComposerCore(
|
|||
}
|
||||
if (completionStatus(view.state) === 'active') return false;
|
||||
const followup = followupStateRef.current;
|
||||
const followupCompletion = getFollowupCompletion(
|
||||
view.state.doc.toString(),
|
||||
followup?.suggestion,
|
||||
);
|
||||
const hasInlineTags = getInlineComposerTags(view).length > 0;
|
||||
const followupCompletion = hasInlineTags
|
||||
? null
|
||||
: getFollowupCompletion(
|
||||
view.state.doc.toString(),
|
||||
followup?.suggestion,
|
||||
);
|
||||
if (followup?.isVisible && followupCompletion) {
|
||||
onAcceptFollowupRef.current?.('enter', { skipOnAccept: true });
|
||||
return submitText(view, followupCompletion);
|
||||
|
|
@ -2081,6 +2375,7 @@ export function useComposerCore(
|
|||
);
|
||||
|
||||
return () => {
|
||||
view.dispatch({ effects: clearInlineTagsEffect.of() });
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
observer.disconnect();
|
||||
|
|
@ -2858,5 +3153,6 @@ export function useComposerCore(
|
|||
enterAtCategory: atMenu.enterCategory,
|
||||
backAtCategories: atMenu.backToCategories,
|
||||
updateAtSearch: atMenu.updateSearch,
|
||||
selectAtTab: atMenu.selectTab,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,21 @@ export type {
|
|||
ToolHeaderExtraRenderer,
|
||||
ToolHeaderExtraRenderInfo,
|
||||
ToolHeaderKind,
|
||||
ComposerTagClickHandler,
|
||||
ComposerTagRenderer,
|
||||
UserMessageContentRenderer,
|
||||
UserMessageContentRenderInfo,
|
||||
UserMessageContentParser,
|
||||
AssistantTurnFooterRenderer,
|
||||
ComposerToolbarStartRenderer,
|
||||
ComposerToolbarRightRenderer,
|
||||
WelcomeFooterRenderer,
|
||||
WebShellAtItemRenderInfo,
|
||||
WebShellAtItemRenderer,
|
||||
WebShellComposerApi,
|
||||
WebShellBuiltinComposerTagKind,
|
||||
WebShellBuiltinAtProviderId,
|
||||
WebShellBuiltinAtProvidersConfig,
|
||||
WebShellComposerInput,
|
||||
WebShellComposerTag,
|
||||
WebShellComposerTagIconMap,
|
||||
|
|
@ -40,13 +47,16 @@ export type {
|
|||
WebShellFooterRenderInfo,
|
||||
FooterRenderer,
|
||||
LoadingPhrasesResolver,
|
||||
WebShellAtProviderTab,
|
||||
WebShellAtItem,
|
||||
WebShellAtProvider,
|
||||
WebShellBottomStatusItem,
|
||||
WebShellAssistantMessageInfo,
|
||||
WebShellAssistantTurnFooterRenderInfo,
|
||||
WebShellCodeBlockRenderInfo,
|
||||
WebShellIconSource,
|
||||
WebShellTaskInfo,
|
||||
WebShellUserMessagePart,
|
||||
WebShellAgentTask,
|
||||
WebShellShellTask,
|
||||
WebShellMonitorTask,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue