{core.composerTags.length > 0 && (
- {core.composerTags.map((tag) => (
-
- {renderComposerTagContent(tag)}
- {tag.removable !== false && (
-
- )}
-
- ))}
+ {core.composerTags.map((tag) => {
+ const tagInfo = {
+ tag,
+ placement: 'composer' as const,
+ readonly: false,
+ };
+ const tooltip = renderComposerTagTooltip?.(tagInfo);
+ return (
+ {
+ 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 && (
+
+ )}
+ {tooltip !== undefined && tooltip !== null && (
+
+ {tooltip}
+
+ )}
+
+ );
+ })}
)}
{core.pastedImages.length > 0 && (
@@ -1455,6 +1520,7 @@ export const ChatEditor = memo(
return Boolean(result);
}}
onSearch={core.updateAtSearch}
+ onSelectTab={core.selectAtTab}
/>
)}
diff --git a/packages/web-shell/client/components/messages/UserMessage.module.css b/packages/web-shell/client/components/messages/UserMessage.module.css
index fc7bd6daf7..bcbb7c9707 100644
--- a/packages/web-shell/client/components/messages/UserMessage.module.css
+++ b/packages/web-shell/client/components/messages/UserMessage.module.css
@@ -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;
diff --git a/packages/web-shell/client/components/messages/UserMessage.test.tsx b/packages/web-shell/client/components/messages/UserMessage.test.tsx
index 4f20f85e33..609638d6c0 100644
--- a/packages/web-shell/client/components/messages/UserMessage.test.tsx
+++ b/packages/web-shell/client/components/messages/UserMessage.test.tsx
@@ -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(
+ [
+ { type: 'text', text: 'open ' },
+ {
+ type: 'tag',
+ tag: {
+ id: 'ctx-1',
+ label: 'Table',
+ value: 'orders',
+ serialized: '',
+ },
+ },
+ ],
+ }}
+ >
+
+ ,
+ );
+
+ 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(
+ [
+ {
+ type: 'tag',
+ tag: {
+ id: 'ctx-1',
+ label: 'Table',
+ value: 'orders',
+ icon: 'javascript:alert(1)',
+ },
+ },
+ ],
+ }}
+ >
+
+ ,
+ );
+
+ 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(
+ [
+ { type: 'text', text: 'explain ' },
+ {
+ type: 'tag',
+ tag: {
+ id: 'ctx-1',
+ label: '@',
+ value: 'project.orders',
+ kind: 'table',
+ serialized: '',
+ },
+ },
+ { type: 'text', text: ' now' },
+ ],
+ }}
+ >
+
+ ,
+ );
+
+ 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(
+ [
+ {
+ type: 'tag',
+ tag: {
+ id: 'ctx-1',
+ value: 'project.orders',
+ kind: 'table',
+ serialized: '',
+ },
+ },
+ ],
+ }}
+ >
+
+ ,
+ );
+
+ 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(
+ [
+ {
+ type: 'tag',
+ tag: {
+ id: 'ctx-1',
+ value: 'project.orders',
+ kind: 'table',
+ serialized: '',
+ },
+ },
+ ],
+ }}
+ >
+
+ ,
+ );
+ 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(
+ {
+ throw new Error('bad host payload');
+ },
+ }}
+ >
+
+ ,
+ );
+
+ expect(container.textContent).toBe('raw 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(
+ [
+ {
+ type: 'tag',
+ tag: {
+ id: 'ctx-1',
+ label: 'Table',
+ value: 'orders',
+ serialized: '',
+ },
+ },
+ ],
+ renderComposerTag: () => {
+ throw renderError;
+ },
+ renderComposerTagTooltip: () => {
+ throw tooltipError;
+ },
+ }}
+ >
+
+ ,
+ );
+
+ 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();
+ });
});
diff --git a/packages/web-shell/client/components/messages/UserMessage.tsx b/packages/web-shell/client/components/messages/UserMessage.tsx
index fbe870d28d..70278976b8 100644
--- a/packages/web-shell/client/components/messages/UserMessage.tsx
+++ b/packages/web-shell/client/components/messages/UserMessage.tsx
@@ -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(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 (
+
+ );
+ });
+ }, [
+ 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({
);
});
+
+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 (
+
{
+ 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 && (
+
+ )}
+ {tagLabel && (
+ {tagLabel}
+ )}
+ {tagValue ? (
+ {tagValue}
+ ) : !tagLabel ? (
+ {tag.id}
+ ) : null}
+ >
+ )}
+ {tooltip !== undefined && tooltip !== null && (
+
+ {tooltip}
+
+ )}
+
+ );
+}
diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx
index fb657b0f87..9f8f2f0617 100644
--- a/packages/web-shell/client/customization.tsx
+++ b/packages/web-shell/client/customization.tsx
@@ -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
;
}
@@ -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;
diff --git a/packages/web-shell/client/hooks/useAtMentionMenu.test.tsx b/packages/web-shell/client/hooks/useAtMentionMenu.test.tsx
index e84db5bac8..b541426a21 100644
--- a/packages/web-shell/client/hooks/useAtMentionMenu.test.tsx
+++ b/packages/web-shell/client/hooks/useAtMentionMenu.test.tsx
@@ -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({
{
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: '',
+ 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: ' ',
+ },
+ 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: '',
+ },
+ });
+ });
+
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('@');
diff --git a/packages/web-shell/client/hooks/useAtMentionMenu.ts b/packages/web-shell/client/hooks/useAtMentionMenu.ts
index 0d52582db5..3743bd055d 100644
--- a/packages/web-shell/client/hooks/useAtMentionMenu.ts
+++ b/packages/web-shell/client/hooks/useAtMentionMenu.ts
@@ -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;
shellModeRef: RefObject;
workspaceActionsRef: RefObject;
+ 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(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 = {
+ ...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,
};
diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx
new file mode 100644
index 0000000000..2a7b77c78f
--- /dev/null
+++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx
@@ -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;
+ renderComposerTag?: () => ReactNode;
+ renderComposerTagTooltip?: () => ReactNode;
+}) {
+ const composer = useComposerCore({
+ onSubmit,
+ commands: [],
+ editorTheme: {},
+ renderComposerTag,
+ renderComposerTagTooltip,
+ composerInput,
+ composerInputVersion: composerInput ? 1 : undefined,
+ });
+ latest = composer;
+
+ return ;
+}
+
+async function mount({
+ composerInput,
+ onSubmit = vi.fn(),
+ renderComposerTag,
+ renderComposerTagTooltip,
+}: {
+ composerInput?: WebShellComposerInput;
+ onSubmit?: ReturnType;
+ renderComposerTag?: () => ReactNode;
+ renderComposerTagTooltip?: () => ReactNode;
+} = {}) {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+
+ await act(async () => {
+ root!.render(
+
+
+ ,
+ );
+ });
+ 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: '' }],
+ { placement: 'inline' },
+ );
+ latest!.insertText('explain');
+ latest!.submitText();
+ });
+
+ expect(onSubmit).toHaveBeenCalledWith(
+ ' explain',
+ undefined,
+ expect.any(Function),
+ );
+ });
+});
diff --git a/packages/web-shell/client/hooks/useComposerCore.test.ts b/packages/web-shell/client/hooks/useComposerCore.test.ts
index 96a387b710..6b8f0c88ce 100644
--- a/packages/web-shell/client/hooks/useComposerCore.test.ts
+++ b/packages/web-shell/client/hooks/useComposerCore.test.ts
@@ -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: '' }],
+ [
+ {
+ start: 8,
+ end: 15,
+ tag: { id: 'table', value: 'orders', serialized: '' },
+ },
+ ],
+ ),
+ ).toBe('\n\nexplain now');
+ });
+
+ it('ignores invalid and overlapping inline tag placements', () => {
+ expect(
+ replaceInlineTagPlacements('a @one and @two', [
+ {
+ start: -1,
+ end: 2,
+ tag: { id: 'negative', serialized: '' },
+ },
+ {
+ start: 2,
+ end: 6,
+ tag: { id: 'one', serialized: '' },
+ },
+ {
+ start: 4,
+ end: 12,
+ tag: { id: 'overlap', serialized: '' },
+ },
+ {
+ start: 11,
+ end: 15,
+ tag: { id: 'two', serialized: '' },
+ },
+ {
+ start: 14,
+ end: 99,
+ tag: { id: 'beyond', serialized: '' },
+ },
+ ]),
+ ).toBe('a and ');
+ });
+
+ it('replaces inline tags before expanding large paste placeholders', () => {
+ const pending = new Map();
+ 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: '' },
+ },
+ ]);
+
+ expect(expandLargePastePlaceholders(pending, withInlineTags)).toBe(
+ 'expanded pasted content that is longer than the placeholder explain ',
+ );
+ });
});
diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts
index 1b369b7392..155e51951b 100644
--- a/packages/web-shell/client/hooks/useComposerCore.ts
+++ b/packages/web-shell/client/hooks/useComposerCore.ts
@@ -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();
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).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).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).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[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,
};
}
diff --git a/packages/web-shell/client/index.ts b/packages/web-shell/client/index.ts
index ab0ae02009..54956ff7b5 100644
--- a/packages/web-shell/client/index.ts
+++ b/packages/web-shell/client/index.ts
@@ -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,