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:
dreamWB 2026-07-10 16:46:43 +08:00 committed by GitHub
parent 37f7d5c69b
commit 24bca9a718
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 2246 additions and 193 deletions

View file

@ -7,17 +7,17 @@
> **实现状态对照(当前 PR**
>
> | 部分 | 状态 |
> | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
> | 删除全局 compactModecontext/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** | ✅ **已实现 + 测试**(方案 Ycore `getToolResponseDisplayText` + live/resume 派生 + `ToolMessage` 数据源切换ACP 经 `transformPartsToToolCallContent` 已带全文,无需新增协议字段;截图 §3.4 待重录) |
> | **鼠标点击工具 block 就地展开§4.8follow-up** | ⏭️ **follow-up独立 PR不在本 PR**——理由见 §4.8type-based 下无 per-tool 点击目标、~250400 行、SGR 选区风险 |
> | 部分 | 状态 |
> | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
> | 删除全局 compactModecontext/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** | ✅ **已实现 + 测试**(方案 Ycore `getToolResponseDisplayText` + live/resume 派生 + `ToolMessage` 数据源切换ACP 经 `transformPartsToToolCallContent` 已带全文,无需新增协议字段;截图 §3.4 待重录) |
> | **鼠标点击工具 block 就地展开§4.8follow-up** | ⏭️ **follow-up独立 PR不在本 PR**——理由见 §4.8type-based 下无 per-tool 点击目标、~250400 行、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)。实现代码已如此 |

View file

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

View file

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

View file

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

View file

@ -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' });

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
});
});

View file

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

View file

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

View file

@ -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('@');

View file

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

View 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),
);
});
});

View file

@ -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 />',
);
});
});

View file

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

View file

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