From 7e9db681fd028f612bb4724d8f4e9eea1ce895ee Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Wed, 8 Jul 2026 17:02:04 +0800 Subject: [PATCH 01/12] feat(web-shell): render composer references in user messages --- .../client/components/ChatEditor.tsx | 12 +- .../client/components/composerTagRender.ts | 118 ++++++++++++++++++ .../messages/UserMessage.module.css | 50 ++++++++ .../components/messages/UserMessage.test.tsx | 26 ++++ .../components/messages/UserMessage.tsx | 54 +++++++- .../web-shell/client/hooks/useComposerCore.ts | 15 ++- 6 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 packages/web-shell/client/components/composerTagRender.ts diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 5761b8d4859..cce4ed3877e 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -29,12 +29,10 @@ import { type EditorHandle, type SlashMenuState, getComposerTagDisplay, - getComposerTagLabel, - getComposerTagValue, } from '../hooks/useComposerCore'; import { AtMentionPanel } from './AtMentionPanel'; import { cssUrlVar } from '../utils/cssUrlVar'; -import { getComposerTagIconUrl } from './composerTagIcons'; +import { getComposerTagRenderParts } from './composerTagRender'; import { ModeIcon } from './ModeIcon'; import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; @@ -1269,12 +1267,10 @@ export const ChatEditor = memo( } = core.searchState; const renderComposerTagContent = (tag: WebShellComposerTag) => { - const rawTagLabel = getComposerTagLabel(tag); - const tagValue = getComposerTagValue(tag); - const tagLabel = tag.kind ? '' : rawTagLabel; - const iconUrl = getComposerTagIconUrl(tag.kind, composerTagIcons); + const { tagLabel, tagValue, iconUrl, fallback } = + getComposerTagRenderParts(tag, composerTagIcons); if (!tagLabel && !tagValue) { - return {tag.id}; + return {fallback}; } return ( <> diff --git a/packages/web-shell/client/components/composerTagRender.ts b/packages/web-shell/client/components/composerTagRender.ts new file mode 100644 index 00000000000..deb5fa86a0c --- /dev/null +++ b/packages/web-shell/client/components/composerTagRender.ts @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; +import type { + WebShellComposerTag, + WebShellComposerTagIconMap, +} from '../customization'; +import { getComposerTagIconUrl } from './composerTagIcons'; + +export interface ComposerTagRenderParts { + tagLabel: string; + tagValue: string; + fallback: string; + iconUrl?: string; +} + +export type ComposerTagReferenceSegment = + | { type: 'text'; text: string } + | { type: 'reference'; tag: WebShellComposerTag }; + +const AT_REFERENCE_CHAR_RE = /[\p{L}\p{N}_./:-]/u; + +function isAtReferenceBoundary(char: string | undefined): boolean { + return char === undefined || /[\s([{'"]/.test(char); +} + +function readAtReferenceEnd(content: string, start: number): number { + let index = start + 1; + while (index < content.length) { + const char = content[index]; + if (char === '\\' && index + 1 < content.length) { + index += 2; + continue; + } + if (!AT_REFERENCE_CHAR_RE.test(char)) break; + index += 1; + } + return index; +} + +function getReferenceDisplay(raw: string, prefix: string) { + const withoutAt = raw.slice(1); + const value = prefix ? withoutAt.slice(prefix.length) : withoutAt; + return value.replace(/\\(.)/g, '$1') || raw; +} + +function createComposerTagFromReference(raw: string): WebShellComposerTag { + if (raw.startsWith('@ext:')) { + return { + id: `extension:${raw}`, + kind: 'extension', + value: getReferenceDisplay(raw, 'ext:'), + serialized: raw, + }; + } + if (raw.startsWith('@mcp:')) { + return { + id: `mcp:${raw}`, + kind: 'mcp', + value: getReferenceDisplay(raw, 'mcp:'), + serialized: raw, + }; + } + return { + id: `file:${raw}`, + kind: 'file', + value: getReferenceDisplay(raw, ''), + serialized: raw, + }; +} + +export function splitComposerTagReferences( + content: string, +): ComposerTagReferenceSegment[] { + const segments: ComposerTagReferenceSegment[] = []; + let cursor = 0; + for (let index = 0; index < content.length; index += 1) { + if ( + content[index] !== '@' || + !isAtReferenceBoundary(content[index - 1]) + ) { + continue; + } + const end = readAtReferenceEnd(content, index); + if (end === index + 1) continue; + if (cursor < index) { + segments.push({ type: 'text', text: content.slice(cursor, index) }); + } + segments.push({ + type: 'reference', + tag: createComposerTagFromReference(content.slice(index, end)), + }); + cursor = end; + index = end - 1; + } + if (cursor < content.length) { + segments.push({ type: 'text', text: content.slice(cursor) }); + } + return segments.length > 0 ? segments : [{ type: 'text', text: content }]; +} + +export function useComposerTagReferences( + content: string, +): ComposerTagReferenceSegment[] { + return useMemo(() => splitComposerTagReferences(content), [content]); +} + +export function getComposerTagRenderParts( + tag: WebShellComposerTag, + composerTagIcons?: WebShellComposerTagIconMap, +): ComposerTagRenderParts { + const rawTagLabel = tag.label?.trim() ?? ''; + const tagValue = tag.value?.trim() ?? ''; + return { + tagLabel: tag.kind ? '' : rawTagLabel, + tagValue, + fallback: tag.id, + iconUrl: getComposerTagIconUrl(tag.kind, composerTagIcons), + }; +} diff --git a/packages/web-shell/client/components/messages/UserMessage.module.css b/packages/web-shell/client/components/messages/UserMessage.module.css index fc7bd6daf73..4f56a1c315f 100644 --- a/packages/web-shell/client/components/messages/UserMessage.module.css +++ b/packages/web-shell/client/components/messages/UserMessage.module.css @@ -63,6 +63,56 @@ word-break: break-word; } +.referenceChip { + 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; +} + +.referenceIcon { + 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; +} + +.referenceLabel, +.referenceValue { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.referenceLabel { + padding: 3px 0 3px 7px; + color: var(--agent-blue-500); +} + +.referenceValue { + max-width: 32ch; + padding: 3px 7px 3px 0.5ch; + color: var(--foreground); +} + +.referenceIcon + .referenceValue { + padding-left: 0.5ch; +} + .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 4f20f85e331..45781e41715 100644 --- a/packages/web-shell/client/components/messages/UserMessage.test.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.test.tsx @@ -35,6 +35,32 @@ describe('UserMessage', () => { expect(container.textContent).toContain('hello world'); }); + it('renders file references as chips by default', () => { + const container = render(); + const chip = container.querySelector('[title="@.qwen/"]'); + + expect(chip).not.toBeNull(); + expect(chip?.textContent).toBe('.qwen/'); + expect(container.textContent).toContain('list .qwen/ files'); + }); + + it('renders extension and MCP references as chips by default', () => { + const container = render( + , + ); + + expect(container.querySelector('[title="@ext:browser"]')).not.toBeNull(); + expect(container.querySelector('[title="@mcp:docs"]')).not.toBeNull(); + expect(container.textContent).toContain('browser and docs'); + }); + + it('does not render inline email addresses as chips', () => { + const container = render(); + + expect(container.querySelector('[title="@b.test"]')).toBeNull(); + expect(container.textContent).toContain('a@b.test'); + }); + it('renders images when provided', () => { const container = render( + {iconUrl && ( + + ); +} + +function DefaultUserMessageContent({ content }: { content: string }) { + const segments = useComposerTagReferences(content); + return ( + <> + {segments.map((segment, index) => + segment.type === 'text' ? ( + {segment.text} + ) : ( + + ), + )} + + ); +} + export const UserMessage = memo(function UserMessage({ content, images, @@ -35,7 +80,10 @@ export const UserMessage = memo(function UserMessage({ const [expanded, setExpanded] = useState(false); const [heightOverflowing, setHeightOverflowing] = useState(false); const renderedContent = useMemo( - () => renderUserMessageContent?.({ content, images }) ?? content, + () => + renderUserMessageContent?.({ content, images }) ?? ( + + ), [content, images, renderUserMessageContent], ); diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 1b369b73920..3c0fd07d89f 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -53,6 +53,7 @@ import { } from '../extensions/inputHighlight'; import { isEditableTarget } from '../utils/dom'; import { cssUrlValue } from '../utils/cssUrlVar'; +import { getComposerTagRenderParts } from '../components/composerTagRender'; import { getComposerTagIconUrl } from '../components/composerTagIcons'; import type { WebShellComposerApi, @@ -557,16 +558,18 @@ class ComposerTagWidget extends WidgetType { const chip = document.createElement('span'); 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;'; - 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 { tagLabel, tagValue, iconUrl } = + getComposerTagRenderParts(this.tag); + const resolvedIconUrl = this.tag.iconUrl ?? iconUrl; - if (iconUrl) { + if (resolvedIconUrl) { 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(resolvedIconUrl), + ); chip.appendChild(icon); } From 1998912799f9011112cb0329165467fa065ebf07 Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Wed, 8 Jul 2026 17:50:38 +0800 Subject: [PATCH 02/12] refactor(web-shell): consolidate composer tag utilities --- .../client/components/ChatEditor.tsx | 4 +- .../components/composerTagIcons.test.ts | 24 --- .../client/components/composerTagIcons.ts | 38 ----- .../components/messages/UserMessage.tsx | 12 +- .../web-shell/client/hooks/useComposerCore.ts | 8 +- .../client/utils/composerTag.test.ts | 144 ++++++++++++++++++ .../composerTag.ts} | 62 ++++++-- 7 files changed, 205 insertions(+), 87 deletions(-) delete mode 100644 packages/web-shell/client/components/composerTagIcons.test.ts delete mode 100644 packages/web-shell/client/components/composerTagIcons.ts create mode 100644 packages/web-shell/client/utils/composerTag.test.ts rename packages/web-shell/client/{components/composerTagRender.ts => utils/composerTag.ts} (60%) diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index cce4ed3877e..9e58e261b42 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -32,7 +32,7 @@ import { } from '../hooks/useComposerCore'; import { AtMentionPanel } from './AtMentionPanel'; import { cssUrlVar } from '../utils/cssUrlVar'; -import { getComposerTagRenderParts } from './composerTagRender'; +import { getComposerTagViewModel } from '../utils/composerTag'; import { ModeIcon } from './ModeIcon'; import { planSlashSectionRows } from '../utils/slashSectionPlan'; import { getModelDisplayName } from '../utils/modelDisplay'; @@ -1268,7 +1268,7 @@ export const ChatEditor = memo( const renderComposerTagContent = (tag: WebShellComposerTag) => { const { tagLabel, tagValue, iconUrl, fallback } = - getComposerTagRenderParts(tag, composerTagIcons); + getComposerTagViewModel(tag, composerTagIcons); if (!tagLabel && !tagValue) { return {fallback}; } diff --git a/packages/web-shell/client/components/composerTagIcons.test.ts b/packages/web-shell/client/components/composerTagIcons.test.ts deleted file mode 100644 index a6af54304a3..00000000000 --- a/packages/web-shell/client/components/composerTagIcons.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getComposerTagIconUrl } from './composerTagIcons'; - -describe('composer tag icons', () => { - it('uses registered icons for custom tag kinds', () => { - expect(getComposerTagIconUrl('table', { table: '/icons/table.svg' })).toBe( - '/icons/table.svg', - ); - }); - - it('falls back to built-in tag icons', () => { - expect(getComposerTagIconUrl('file')).toBeTruthy(); - }); - - it('ignores inherited object properties', () => { - const icons = Object.create({ table: '/icons/table.svg' }) as Record< - string, - string - >; - - expect(getComposerTagIconUrl('table', icons)).toBeUndefined(); - expect(getComposerTagIconUrl('toString')).toBeUndefined(); - }); -}); diff --git a/packages/web-shell/client/components/composerTagIcons.ts b/packages/web-shell/client/components/composerTagIcons.ts deleted file mode 100644 index c6225fd3230..00000000000 --- a/packages/web-shell/client/components/composerTagIcons.ts +++ /dev/null @@ -1,38 +0,0 @@ -import extensionIconUrl from '../assets/icons/at-extension.svg'; -import fileIconUrl from '../assets/icons/at-file.svg'; -import mcpIconUrl from '../assets/icons/at-mcp.svg'; -import skillIconUrl from '../assets/icons/at-skill.svg'; -import type { - WebShellBuiltinComposerTagKind, - WebShellComposerTagIconMap, - WebShellComposerTagKind, -} from '../customization'; - -const builtinTagIconUrls: Record = { - extension: extensionIconUrl, - file: fileIconUrl, - mcp: mcpIconUrl, - skill: skillIconUrl, -}; - -function getOwnIconUrl( - iconUrls: WebShellComposerTagIconMap | undefined, - kind: string, -): string | undefined { - if (!iconUrls || !Object.prototype.hasOwnProperty.call(iconUrls, kind)) { - return undefined; - } - const iconUrl = iconUrls[kind]; - return typeof iconUrl === 'string' ? iconUrl : undefined; -} - -export function getComposerTagIconUrl( - kind: WebShellComposerTagKind | undefined, - customIconUrls?: WebShellComposerTagIconMap, -): string | undefined { - if (!kind) return undefined; - return ( - getOwnIconUrl(customIconUrls, kind) ?? - getOwnIconUrl(builtinTagIconUrls, kind) - ); -} diff --git a/packages/web-shell/client/components/messages/UserMessage.tsx b/packages/web-shell/client/components/messages/UserMessage.tsx index cd9f498bee9..a74af58cad4 100644 --- a/packages/web-shell/client/components/messages/UserMessage.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.tsx @@ -9,9 +9,9 @@ import { useState, } from 'react'; import { - getComposerTagRenderParts, - useComposerTagReferences, -} from '../composerTagRender'; + getComposerTagViewModel, + splitComposerTagContent, +} from '../../utils/composerTag'; import { isSafeImageSrc } from './Markdown'; import { useWebShellCustomization, @@ -35,7 +35,7 @@ interface UserMessageProps { function UserMessageReferenceChip({ tag }: { tag: WebShellComposerTag }) { const { tagLabel, tagValue, iconUrl, fallback } = - getComposerTagRenderParts(tag); + getComposerTagViewModel(tag); return ( {iconUrl && ( @@ -52,7 +52,9 @@ function UserMessageReferenceChip({ tag }: { tag: WebShellComposerTag }) { } function DefaultUserMessageContent({ content }: { content: string }) { - const segments = useComposerTagReferences(content); + // The default user renderer upgrades serialized @ references to chips while + // still allowing hosts to replace message rendering entirely. + const segments = useMemo(() => splitComposerTagContent(content), [content]); return ( <> {segments.map((segment, index) => diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 3c0fd07d89f..c64c3bd5dfb 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -53,8 +53,10 @@ import { } from '../extensions/inputHighlight'; import { isEditableTarget } from '../utils/dom'; import { cssUrlValue } from '../utils/cssUrlVar'; -import { getComposerTagRenderParts } from '../components/composerTagRender'; -import { getComposerTagIconUrl } from '../components/composerTagIcons'; +import { + getComposerTagIconUrl, + getComposerTagViewModel, +} from '../utils/composerTag'; import type { WebShellComposerApi, WebShellComposerInput, @@ -559,7 +561,7 @@ class ComposerTagWidget extends WidgetType { 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;'; const { tagLabel, tagValue, iconUrl } = - getComposerTagRenderParts(this.tag); + getComposerTagViewModel(this.tag); const resolvedIconUrl = this.tag.iconUrl ?? iconUrl; if (resolvedIconUrl) { diff --git a/packages/web-shell/client/utils/composerTag.test.ts b/packages/web-shell/client/utils/composerTag.test.ts new file mode 100644 index 00000000000..b74e0417cb7 --- /dev/null +++ b/packages/web-shell/client/utils/composerTag.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { + getComposerTagIconUrl, + getComposerTagViewModel, + splitComposerTagContent, +} from './composerTag'; + +describe('composer tag icon URLs', () => { + it('uses registered icons for custom tag kinds', () => { + expect(getComposerTagIconUrl('table', { table: '/icons/table.svg' })).toBe( + '/icons/table.svg', + ); + }); + + it('falls back to built-in tag icons', () => { + expect(getComposerTagIconUrl('file')).toBeTruthy(); + }); + + it('ignores inherited object properties', () => { + const icons = Object.create({ table: '/icons/table.svg' }) as Record< + string, + string + >; + + expect(getComposerTagIconUrl('table', icons)).toBeUndefined(); + expect(getComposerTagIconUrl('toString')).toBeUndefined(); + }); +}); + +describe('splitComposerTagContent', () => { + it('splits file references into text and reference segments', () => { + expect(splitComposerTagContent('list @.qwen/ files')).toEqual([ + { type: 'text', text: 'list ' }, + { + type: 'reference', + tag: { + id: 'file:@.qwen/', + kind: 'file', + value: '.qwen/', + serialized: '@.qwen/', + }, + }, + { type: 'text', text: ' files' }, + ]); + }); + + it('creates extension and MCP reference tags', () => { + expect(splitComposerTagContent('@ext:browser and @mcp:docs')).toEqual([ + { + type: 'reference', + tag: { + id: 'extension:@ext:browser', + kind: 'extension', + value: 'browser', + serialized: '@ext:browser', + }, + }, + { type: 'text', text: ' and ' }, + { + type: 'reference', + tag: { + id: 'mcp:@mcp:docs', + kind: 'mcp', + value: 'docs', + serialized: '@mcp:docs', + }, + }, + ]); + }); + + it('does not split inline email-like text', () => { + expect(splitComposerTagContent('mail a@b.test')).toEqual([ + { type: 'text', text: 'mail a@b.test' }, + ]); + }); + + it('unescapes reference display text', () => { + expect(splitComposerTagContent('open @path\\ with\\ spaces')).toEqual([ + { type: 'text', text: 'open ' }, + { + type: 'reference', + tag: { + id: 'file:@path\\ with\\ spaces', + kind: 'file', + value: 'path with spaces', + serialized: '@path\\ with\\ spaces', + }, + }, + ]); + }); +}); + +describe('getComposerTagViewModel', () => { + it('returns display fields for custom tags', () => { + expect( + getComposerTagViewModel({ + id: 'custom:1', + label: ' Dataset ', + value: ' users.csv ', + }), + ).toEqual({ + tagLabel: 'Dataset', + tagValue: 'users.csv', + fallback: 'custom:1', + iconUrl: undefined, + }); + }); + + it('hides labels for built-in tag kinds and resolves icons', () => { + const model = getComposerTagViewModel({ + id: 'file:@.qwen/', + kind: 'file', + label: 'File', + value: '.qwen/', + }); + + expect(model.tagLabel).toBe(''); + expect(model.tagValue).toBe('.qwen/'); + expect(model.fallback).toBe('file:@.qwen/'); + expect(model.iconUrl).toBeTruthy(); + }); + + it('uses custom icon maps when provided', () => { + expect( + getComposerTagViewModel( + { + id: 'file:@src/index.ts', + kind: 'file', + value: 'src/index.ts', + }, + { file: '/custom-file.svg' }, + ).iconUrl, + ).toBe('/custom-file.svg'); + }); + + it('uses fallback when tag has no display text', () => { + expect(getComposerTagViewModel({ id: 'tag-id' })).toEqual({ + tagLabel: '', + tagValue: '', + fallback: 'tag-id', + iconUrl: undefined, + }); + }); +}); diff --git a/packages/web-shell/client/components/composerTagRender.ts b/packages/web-shell/client/utils/composerTag.ts similarity index 60% rename from packages/web-shell/client/components/composerTagRender.ts rename to packages/web-shell/client/utils/composerTag.ts index deb5fa86a0c..84f4c4aaaad 100644 --- a/packages/web-shell/client/components/composerTagRender.ts +++ b/packages/web-shell/client/utils/composerTag.ts @@ -1,23 +1,47 @@ -import { useMemo } from 'react'; +import extensionIconUrl from '../assets/icons/at-extension.svg'; +import fileIconUrl from '../assets/icons/at-file.svg'; +import mcpIconUrl from '../assets/icons/at-mcp.svg'; +import skillIconUrl from '../assets/icons/at-skill.svg'; import type { + WebShellBuiltinComposerTagKind, WebShellComposerTag, WebShellComposerTagIconMap, + WebShellComposerTagKind, } from '../customization'; -import { getComposerTagIconUrl } from './composerTagIcons'; -export interface ComposerTagRenderParts { +// Shared UI-facing shape for composer tags across React and CodeMirror. +export interface ComposerTagViewModel { tagLabel: string; tagValue: string; fallback: string; iconUrl?: string; } -export type ComposerTagReferenceSegment = +export type ComposerTagContentSegment = | { type: 'text'; text: string } | { type: 'reference'; tag: WebShellComposerTag }; +// Resolves tag kind metadata to asset URLs; it does not render icon components. +const builtinTagIconUrls: Record = { + extension: extensionIconUrl, + file: fileIconUrl, + mcp: mcpIconUrl, + skill: skillIconUrl, +}; + const AT_REFERENCE_CHAR_RE = /[\p{L}\p{N}_./:-]/u; +function getOwnIconUrl( + iconUrls: WebShellComposerTagIconMap | undefined, + kind: string, +): string | undefined { + if (!iconUrls || !Object.prototype.hasOwnProperty.call(iconUrls, kind)) { + return undefined; + } + const iconUrl = iconUrls[kind]; + return typeof iconUrl === 'string' ? iconUrl : undefined; +} + function isAtReferenceBoundary(char: string | undefined): boolean { return char === undefined || /[\s([{'"]/.test(char); } @@ -67,10 +91,23 @@ function createComposerTagFromReference(raw: string): WebShellComposerTag { }; } -export function splitComposerTagReferences( +export function getComposerTagIconUrl( + kind: WebShellComposerTagKind | undefined, + customIconUrls?: WebShellComposerTagIconMap, +): string | undefined { + if (!kind) return undefined; + return ( + getOwnIconUrl(customIconUrls, kind) ?? + getOwnIconUrl(builtinTagIconUrls, kind) + ); +} + +// Sent user messages persist serialized prompt text, so reference chips must be +// reconstructed from @ references when rendering the transcript. +export function splitComposerTagContent( content: string, -): ComposerTagReferenceSegment[] { - const segments: ComposerTagReferenceSegment[] = []; +): ComposerTagContentSegment[] { + const segments: ComposerTagContentSegment[] = []; let cursor = 0; for (let index = 0; index < content.length; index += 1) { if ( @@ -97,16 +134,11 @@ export function splitComposerTagReferences( return segments.length > 0 ? segments : [{ type: 'text', text: content }]; } -export function useComposerTagReferences( - content: string, -): ComposerTagReferenceSegment[] { - return useMemo(() => splitComposerTagReferences(content), [content]); -} - -export function getComposerTagRenderParts( +// Normalizes display fields so each renderer can keep its own shell and styles. +export function getComposerTagViewModel( tag: WebShellComposerTag, composerTagIcons?: WebShellComposerTagIconMap, -): ComposerTagRenderParts { +): ComposerTagViewModel { const rawTagLabel = tag.label?.trim() ?? ''; const tagValue = tag.value?.trim() ?? ''; return { From 60c36126ba2f0a691e96c44bc0be04087190de09 Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Wed, 8 Jul 2026 19:39:46 +0800 Subject: [PATCH 03/12] fix(web-shell): leave custom references as text --- .../components/messages/UserMessage.test.tsx | 7 +++++ .../client/utils/composerTag.test.ts | 21 +++++++++++++++ .../web-shell/client/utils/composerTag.ts | 27 ++++++++++++++----- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/web-shell/client/components/messages/UserMessage.test.tsx b/packages/web-shell/client/components/messages/UserMessage.test.tsx index 45781e41715..9a3fa102292 100644 --- a/packages/web-shell/client/components/messages/UserMessage.test.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.test.tsx @@ -61,6 +61,13 @@ describe('UserMessage', () => { expect(container.textContent).toContain('a@b.test'); }); + it('keeps custom provider references as text by default', () => { + const container = render(); + + expect(container.querySelector('[title="@dataset:users"]')).toBeNull(); + expect(container.textContent).toContain('open @dataset:users'); + }); + it('renders images when provided', () => { const container = render( { ]); }); + it('keeps custom provider-prefixed references as text', () => { + expect(splitComposerTagContent('open @dataset:users')).toEqual([ + { type: 'text', text: 'open @dataset:users' }, + ]); + }); + + it('keeps Windows drive references as file tags', () => { + expect(splitComposerTagContent('open @C:/Users/name/file.ts')).toEqual([ + { type: 'text', text: 'open ' }, + { + type: 'reference', + tag: { + id: 'file:@C:/Users/name/file.ts', + kind: 'file', + value: 'C:/Users/name/file.ts', + serialized: '@C:/Users/name/file.ts', + }, + }, + ]); + }); + it('unescapes reference display text', () => { expect(splitComposerTagContent('open @path\\ with\\ spaces')).toEqual([ { type: 'text', text: 'open ' }, diff --git a/packages/web-shell/client/utils/composerTag.ts b/packages/web-shell/client/utils/composerTag.ts index 84f4c4aaaad..e845474d088 100644 --- a/packages/web-shell/client/utils/composerTag.ts +++ b/packages/web-shell/client/utils/composerTag.ts @@ -66,7 +66,22 @@ function getReferenceDisplay(raw: string, prefix: string) { return value.replace(/\\(.)/g, '$1') || raw; } -function createComposerTagFromReference(raw: string): WebShellComposerTag { +// Custom providers can use the @provider:item shape, but user messages only +// persist serialized text. Without provider metadata, default rendering should +// leave those references as text instead of guessing they are file chips. +function isCustomProviderReference(raw: string): boolean { + const value = raw.slice(1); + const colonIndex = value.indexOf(':'); + if (colonIndex <= 0) return false; + const prefix = value.slice(0, colonIndex); + if (prefix === 'ext' || prefix === 'mcp') return false; + if (prefix.length === 1 && value[colonIndex + 1] === '/') return false; + return /^[\p{L}\p{N}_-]+$/u.test(prefix); +} + +function createComposerTagFromReference( + raw: string, +): WebShellComposerTag | null { if (raw.startsWith('@ext:')) { return { id: `extension:${raw}`, @@ -83,6 +98,7 @@ function createComposerTagFromReference(raw: string): WebShellComposerTag { serialized: raw, }; } + if (isCustomProviderReference(raw)) return null; return { id: `file:${raw}`, kind: 'file', @@ -110,20 +126,19 @@ export function splitComposerTagContent( const segments: ComposerTagContentSegment[] = []; let cursor = 0; for (let index = 0; index < content.length; index += 1) { - if ( - content[index] !== '@' || - !isAtReferenceBoundary(content[index - 1]) - ) { + if (content[index] !== '@' || !isAtReferenceBoundary(content[index - 1])) { continue; } const end = readAtReferenceEnd(content, index); if (end === index + 1) continue; + const tag = createComposerTagFromReference(content.slice(index, end)); + if (!tag) continue; if (cursor < index) { segments.push({ type: 'text', text: content.slice(cursor, index) }); } segments.push({ type: 'reference', - tag: createComposerTagFromReference(content.slice(index, end)), + tag, }); cursor = end; index = end - 1; From f91e06094d5fdb886cbbb028652c696aaa4d8acb Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Thu, 9 Jul 2026 10:52:17 +0800 Subject: [PATCH 04/12] fix(web-shell): avoid ambiguous reference chips --- .../messages/UserMessage.module.css | 1 + .../components/messages/UserMessage.test.tsx | 20 +++ .../components/messages/UserMessage.tsx | 34 +++-- .../web-shell/client/hooks/useComposerCore.ts | 3 +- .../client/utils/composerTag.test.ts | 119 +++++++++++++++++- .../web-shell/client/utils/composerTag.ts | 97 ++++++++++---- 6 files changed, 238 insertions(+), 36 deletions(-) diff --git a/packages/web-shell/client/components/messages/UserMessage.module.css b/packages/web-shell/client/components/messages/UserMessage.module.css index 4f56a1c315f..be197b48449 100644 --- a/packages/web-shell/client/components/messages/UserMessage.module.css +++ b/packages/web-shell/client/components/messages/UserMessage.module.css @@ -63,6 +63,7 @@ word-break: break-word; } +/* Keep visual styles in sync with ComposerTagWidget in useComposerCore.ts. */ .referenceChip { display: inline-flex; max-width: min(44ch, 100%); diff --git a/packages/web-shell/client/components/messages/UserMessage.test.tsx b/packages/web-shell/client/components/messages/UserMessage.test.tsx index 9a3fa102292..fe14f443beb 100644 --- a/packages/web-shell/client/components/messages/UserMessage.test.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.test.tsx @@ -44,6 +44,26 @@ describe('UserMessage', () => { expect(container.textContent).toContain('list .qwen/ files'); }); + it('uses configured composer tag icons for default chips', () => { + const container = render( + + + , + ); + const icon = container.querySelector( + '[title="@.qwen/"] [aria-hidden="true"]', + ); + + expect(icon).not.toBeNull(); + expect(icon?.style.getPropertyValue('--composer-tag-icon-url')).toBe( + 'url("/custom-file.svg")', + ); + }); + it('renders extension and MCP references as chips by default', () => { const container = render( , diff --git a/packages/web-shell/client/components/messages/UserMessage.tsx b/packages/web-shell/client/components/messages/UserMessage.tsx index a74af58cad4..6a2af25e65b 100644 --- a/packages/web-shell/client/components/messages/UserMessage.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.tsx @@ -16,6 +16,7 @@ import { isSafeImageSrc } from './Markdown'; import { useWebShellCustomization, type WebShellComposerTag, + type WebShellComposerTagIconMap, } from '../../customization'; import { useI18n } from '../../i18n'; import { cssUrlVar } from '../../utils/cssUrlVar'; @@ -33,9 +34,17 @@ interface UserMessageProps { isLocateFlashing?: boolean; } -function UserMessageReferenceChip({ tag }: { tag: WebShellComposerTag }) { - const { tagLabel, tagValue, iconUrl, fallback } = - getComposerTagViewModel(tag); +function UserMessageReferenceChip({ + composerTagIcons, + tag, +}: { + composerTagIcons?: WebShellComposerTagIconMap; + tag: WebShellComposerTag; +}) { + const { tagLabel, tagValue, iconUrl, fallback } = getComposerTagViewModel( + tag, + composerTagIcons, + ); return ( {iconUrl && ( @@ -51,7 +60,13 @@ function UserMessageReferenceChip({ tag }: { tag: WebShellComposerTag }) { ); } -function DefaultUserMessageContent({ content }: { content: string }) { +function DefaultUserMessageContent({ + composerTagIcons, + content, +}: { + composerTagIcons?: WebShellComposerTagIconMap; + content: string; +}) { // The default user renderer upgrades serialized @ references to chips while // still allowing hosts to replace message rendering entirely. const segments = useMemo(() => splitComposerTagContent(content), [content]); @@ -62,6 +77,7 @@ function DefaultUserMessageContent({ content }: { content: string }) { {segment.text} ) : ( @@ -77,16 +93,20 @@ export const UserMessage = memo(function UserMessage({ isLocateFlashing = false, }: UserMessageProps) { const { t } = useI18n(); - const { renderUserMessageContent } = useWebShellCustomization(); + const { composerTagIcons, renderUserMessageContent } = + useWebShellCustomization(); const contentRef = useRef(null); const [expanded, setExpanded] = useState(false); const [heightOverflowing, setHeightOverflowing] = useState(false); const renderedContent = useMemo( () => renderUserMessageContent?.({ content, images }) ?? ( - + ), - [content, images, renderUserMessageContent], + [composerTagIcons, content, images, renderUserMessageContent], ); const measureOverflow = useCallback(() => { diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index c64c3bd5dfb..ea0bc57137c 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -560,8 +560,7 @@ class ComposerTagWidget extends WidgetType { const chip = document.createElement('span'); 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;'; - const { tagLabel, tagValue, iconUrl } = - getComposerTagViewModel(this.tag); + const { tagLabel, tagValue, iconUrl } = getComposerTagViewModel(this.tag); const resolvedIconUrl = this.tag.iconUrl ?? iconUrl; if (resolvedIconUrl) { diff --git a/packages/web-shell/client/utils/composerTag.test.ts b/packages/web-shell/client/utils/composerTag.test.ts index f4e92c58dc9..08d1f412787 100644 --- a/packages/web-shell/client/utils/composerTag.test.ts +++ b/packages/web-shell/client/utils/composerTag.test.ts @@ -28,6 +28,11 @@ describe('composer tag icon URLs', () => { }); describe('splitComposerTagContent', () => { + it('returns text for empty content and bare @', () => { + expect(splitComposerTagContent('')).toEqual([{ type: 'text', text: '' }]); + expect(splitComposerTagContent('@')).toEqual([{ type: 'text', text: '@' }]); + }); + it('splits file references into text and reference segments', () => { expect(splitComposerTagContent('list @.qwen/ files')).toEqual([ { type: 'text', text: 'list ' }, @@ -44,6 +49,21 @@ describe('splitComposerTagContent', () => { ]); }); + it('keeps adjacent non-boundary references as text', () => { + expect(splitComposerTagContent('@.qwen/@src/index.ts')).toEqual([ + { + type: 'reference', + tag: { + id: 'file:@.qwen/', + kind: 'file', + value: '.qwen/', + serialized: '@.qwen/', + }, + }, + { type: 'text', text: '@src/index.ts' }, + ]); + }); + it('creates extension and MCP reference tags', () => { expect(splitComposerTagContent('@ext:browser and @mcp:docs')).toEqual([ { @@ -68,18 +88,60 @@ describe('splitComposerTagContent', () => { ]); }); + it('creates MCP resource tags from escaped server resource references', () => { + expect(splitComposerTagContent('open @docs\\:res\\://doc')).toEqual([ + { type: 'text', text: 'open ' }, + { + type: 'reference', + tag: { + id: 'mcp:@docs\\:res\\://doc', + kind: 'mcp', + value: 'docs:res://doc', + serialized: '@docs\\:res\\://doc', + }, + }, + ]); + }); + it('does not split inline email-like text', () => { expect(splitComposerTagContent('mail a@b.test')).toEqual([ { type: 'text', text: 'mail a@b.test' }, ]); }); + it('keeps package and handle-like references as text', () => { + expect( + splitComposerTagContent('install @types/node and ask @alice'), + ).toEqual([{ type: 'text', text: 'install @types/node and ask @alice' }]); + }); + + it('does not split references after non-boundary characters', () => { + expect(splitComposerTagContent('prefix/@.qwen/')).toEqual([ + { type: 'text', text: 'prefix/@.qwen/' }, + ]); + }); + it('keeps custom provider-prefixed references as text', () => { expect(splitComposerTagContent('open @dataset:users')).toEqual([ { type: 'text', text: 'open @dataset:users' }, ]); }); + it('keeps short Windows drive references as file tags', () => { + expect(splitComposerTagContent('open @C:file')).toEqual([ + { type: 'text', text: 'open ' }, + { + type: 'reference', + tag: { + id: 'file:@C:file', + kind: 'file', + value: 'C:file', + serialized: '@C:file', + }, + }, + ]); + }); + it('keeps Windows drive references as file tags', () => { expect(splitComposerTagContent('open @C:/Users/name/file.ts')).toEqual([ { type: 'text', text: 'open ' }, @@ -96,17 +158,50 @@ describe('splitComposerTagContent', () => { }); it('unescapes reference display text', () => { - expect(splitComposerTagContent('open @path\\ with\\ spaces')).toEqual([ + expect( + splitComposerTagContent('open @path/file\\ with\\ spaces.txt'), + ).toEqual([ { type: 'text', text: 'open ' }, { type: 'reference', tag: { - id: 'file:@path\\ with\\ spaces', + id: 'file:@path/file\\ with\\ spaces.txt', + kind: 'file', + value: 'path/file with spaces.txt', + serialized: '@path/file\\ with\\ spaces.txt', + }, + }, + ]); + }); + + it('advances over escaped emoji code points', () => { + expect(splitComposerTagContent('open @path/\\😀.txt')).toEqual([ + { type: 'text', text: 'open ' }, + { + type: 'reference', + tag: { + id: 'file:@path/\\😀.txt', + kind: 'file', + value: 'path/😀.txt', + serialized: '@path/\\😀.txt', + }, + }, + ]); + }); + + it('leaves trailing sentence punctuation outside chips', () => { + expect(splitComposerTagContent('See @file.ts.')).toEqual([ + { type: 'text', text: 'See ' }, + { + type: 'reference', + tag: { + id: 'file:@file.ts', kind: 'file', - value: 'path with spaces', - serialized: '@path\\ with\\ spaces', + value: 'file.ts', + serialized: '@file.ts', }, }, + { type: 'text', text: '.' }, ]); }); }); @@ -162,4 +257,20 @@ describe('getComposerTagViewModel', () => { iconUrl: undefined, }); }); + + it('keeps labels for custom tag kinds', () => { + expect( + getComposerTagViewModel({ + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + }), + ).toEqual({ + tagLabel: 'Dataset', + tagValue: 'users', + fallback: 'dataset:users', + iconUrl: undefined, + }); + }); }); diff --git a/packages/web-shell/client/utils/composerTag.ts b/packages/web-shell/client/utils/composerTag.ts index e845474d088..53f8b9d1d09 100644 --- a/packages/web-shell/client/utils/composerTag.ts +++ b/packages/web-shell/client/utils/composerTag.ts @@ -30,6 +30,7 @@ const builtinTagIconUrls: Record = { }; const AT_REFERENCE_CHAR_RE = /[\p{L}\p{N}_./:-]/u; +const WINDOWS_DRIVE_REFERENCE_RE = /^[A-Za-z]:/; function getOwnIconUrl( iconUrls: WebShellComposerTagIconMap | undefined, @@ -51,7 +52,8 @@ function readAtReferenceEnd(content: string, start: number): number { while (index < content.length) { const char = content[index]; if (char === '\\' && index + 1 < content.length) { - index += 2; + const escapedCodePoint = content.codePointAt(index + 1); + index += 1 + (escapedCodePoint && escapedCodePoint > 0xffff ? 2 : 1); continue; } if (!AT_REFERENCE_CHAR_RE.test(char)) break; @@ -60,23 +62,55 @@ function readAtReferenceEnd(content: string, start: number): number { return index; } +function trimReferenceTrailingPunctuation( + content: string, + end: number, +): number { + let nextEnd = end; + while (nextEnd > 0) { + const last = content[nextEnd - 1]; + if ( + (last !== '.' && last !== ':') || + !isAtReferenceBoundary(content[nextEnd]) + ) { + break; + } + nextEnd -= 1; + } + return nextEnd; +} + function getReferenceDisplay(raw: string, prefix: string) { const withoutAt = raw.slice(1); const value = prefix ? withoutAt.slice(prefix.length) : withoutAt; - return value.replace(/\\(.)/g, '$1') || raw; + return value.replace(/\\(.)/gu, '$1') || raw; } -// Custom providers can use the @provider:item shape, but user messages only -// persist serialized text. Without provider metadata, default rendering should -// leave those references as text instead of guessing they are file chips. -function isCustomProviderReference(raw: string): boolean { +// MCP resources are serialized as @server\:uri, with the server/URI delimiter +// escaped by the composer. That is the only provider-prefixed form this parser +// can recover without persisted composer tag metadata. +function isEscapedMcpResourceReference(raw: string): boolean { const value = raw.slice(1); - const colonIndex = value.indexOf(':'); - if (colonIndex <= 0) return false; - const prefix = value.slice(0, colonIndex); - if (prefix === 'ext' || prefix === 'mcp') return false; - if (prefix.length === 1 && value[colonIndex + 1] === '/') return false; - return /^[\p{L}\p{N}_-]+$/u.test(prefix); + const escapedDelimiterIndex = value.indexOf('\\:'); + if (escapedDelimiterIndex <= 0) return false; + const unescaped = getReferenceDisplay(raw, ''); + const delimiterIndex = unescaped.indexOf(':'); + if (delimiterIndex <= 0) return false; + return unescaped.slice(delimiterIndex + 1).includes(':'); +} + +// User messages persist only text, so ambiguous mentions like @alice or +// @types/node must remain text. Only chipify file references with path cues. +function isBuiltInFileReference(raw: string): boolean { + const value = getReferenceDisplay(raw, ''); + return ( + value.startsWith('./') || + value.startsWith('../') || + value.startsWith('/') || + value.endsWith('/') || + value.includes('.') || + WINDOWS_DRIVE_REFERENCE_RE.test(value) + ); } function createComposerTagFromReference( @@ -98,13 +132,23 @@ function createComposerTagFromReference( serialized: raw, }; } - if (isCustomProviderReference(raw)) return null; - return { - id: `file:${raw}`, - kind: 'file', - value: getReferenceDisplay(raw, ''), - serialized: raw, - }; + if (isEscapedMcpResourceReference(raw)) { + return { + id: `mcp:${raw}`, + kind: 'mcp', + value: getReferenceDisplay(raw, ''), + serialized: raw, + }; + } + if (isBuiltInFileReference(raw)) { + return { + id: `file:${raw}`, + kind: 'file', + value: getReferenceDisplay(raw, ''), + serialized: raw, + }; + } + return null; } export function getComposerTagIconUrl( @@ -131,7 +175,11 @@ export function splitComposerTagContent( } const end = readAtReferenceEnd(content, index); if (end === index + 1) continue; - const tag = createComposerTagFromReference(content.slice(index, end)); + const referenceEnd = trimReferenceTrailingPunctuation(content, end); + if (referenceEnd === index + 1) continue; + const tag = createComposerTagFromReference( + content.slice(index, referenceEnd), + ); if (!tag) continue; if (cursor < index) { segments.push({ type: 'text', text: content.slice(cursor, index) }); @@ -140,8 +188,8 @@ export function splitComposerTagContent( type: 'reference', tag, }); - cursor = end; - index = end - 1; + cursor = referenceEnd; + index = referenceEnd - 1; } if (cursor < content.length) { segments.push({ type: 'text', text: content.slice(cursor) }); @@ -156,8 +204,11 @@ export function getComposerTagViewModel( ): ComposerTagViewModel { const rawTagLabel = tag.label?.trim() ?? ''; const tagValue = tag.value?.trim() ?? ''; + const isBuiltinTag = tag.kind + ? getOwnIconUrl(builtinTagIconUrls, tag.kind) + : undefined; return { - tagLabel: tag.kind ? '' : rawTagLabel, + tagLabel: isBuiltinTag ? '' : rawTagLabel, tagValue, fallback: tag.id, iconUrl: getComposerTagIconUrl(tag.kind, composerTagIcons), From 4a391c0f330241ade95a76f7b34ef7c5086d35f9 Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Thu, 9 Jul 2026 11:14:12 +0800 Subject: [PATCH 05/12] fix(web-shell): thread composer tag icons to messages --- packages/web-shell/client/App.tsx | 2 ++ packages/web-shell/client/customization.tsx | 1 + packages/web-shell/client/utils/composerTag.test.ts | 6 ++++++ 3 files changed, 9 insertions(+) diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 93608156406..06a8a9d78b3 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -906,6 +906,7 @@ export function App({ }, []); const customization = useMemo( () => ({ + composerTagIcons, renderToolHeaderExtra, renderWelcomeHeader, renderWelcomeFooter, @@ -921,6 +922,7 @@ export function App({ loadingPhrases, }), [ + composerTagIcons, renderToolHeaderExtra, renderWelcomeHeader, renderWelcomeFooter, diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 2f1858be433..b8d7141afc9 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -283,6 +283,7 @@ export type LoadingPhrasesResolver = ( ) => readonly string[] | undefined | null; export interface WebShellCustomization { + composerTagIcons?: WebShellComposerTagIconMap; renderToolHeaderExtra?: ToolHeaderExtraRenderer; renderWelcomeHeader?: WelcomeHeaderRenderer; renderWelcomeFooter?: WelcomeFooterRenderer; diff --git a/packages/web-shell/client/utils/composerTag.test.ts b/packages/web-shell/client/utils/composerTag.test.ts index 08d1f412787..c21541623ea 100644 --- a/packages/web-shell/client/utils/composerTag.test.ts +++ b/packages/web-shell/client/utils/composerTag.test.ts @@ -31,6 +31,12 @@ describe('splitComposerTagContent', () => { it('returns text for empty content and bare @', () => { expect(splitComposerTagContent('')).toEqual([{ type: 'text', text: '' }]); expect(splitComposerTagContent('@')).toEqual([{ type: 'text', text: '@' }]); + expect(splitComposerTagContent('hello @ world')).toEqual([ + { type: 'text', text: 'hello @ world' }, + ]); + expect(splitComposerTagContent('end @')).toEqual([ + { type: 'text', text: 'end @' }, + ]); }); it('splits file references into text and reference segments', () => { From 0641e1ea504681182199de37b57c7ae24ac32f92 Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Fri, 10 Jul 2026 13:26:52 +0800 Subject: [PATCH 06/12] feat(web-shell): render user references from annotations --- ...webshell-user-message-input-annotations.md | 396 ++++++++++++++++ packages/acp-bridge/src/bridge.test.ts | 21 + packages/acp-bridge/src/bridge.ts | 14 +- packages/sdk-typescript/src/daemon/index.ts | 3 + .../sdk-typescript/src/daemon/ui/index.ts | 3 + .../sdk-typescript/src/daemon/ui/store.ts | 4 +- .../src/daemon/ui/transcript.ts | 11 +- .../sdk-typescript/src/daemon/ui/types.ts | 21 + .../sdk-typescript/test/unit/daemonUi.test.ts | 66 +++ packages/web-shell/client/App.tsx | 67 ++- .../web-shell/client/adapters/messageTypes.ts | 3 + .../adapters/transcriptToMessages.test.ts | 30 ++ .../client/adapters/transcriptToMessages.ts | 6 + .../client/components/ChatEditor.tsx | 8 +- .../client/components/MessageItem.tsx | 1 + .../client/components/QueuedPromptDisplay.tsx | 2 + .../components/messages/UserMessage.test.tsx | 220 ++++++++- .../components/messages/UserMessage.tsx | 27 +- packages/web-shell/client/customization.tsx | 2 + .../web-shell/client/hooks/useComposerCore.ts | 32 +- .../client/hooks/useQueuedPrompts.ts | 24 +- .../client/utils/composerTag.test.ts | 437 +++++++++++------- .../web-shell/client/utils/composerTag.ts | 188 +++----- packages/webui/src/daemon/session/actions.ts | 26 +- packages/webui/src/daemon/session/types.ts | 2 + 25 files changed, 1267 insertions(+), 347 deletions(-) create mode 100644 docs/design/2026-07-09-webshell-user-message-input-annotations.md diff --git a/docs/design/2026-07-09-webshell-user-message-input-annotations.md b/docs/design/2026-07-09-webshell-user-message-input-annotations.md new file mode 100644 index 00000000000..0fb41dbb738 --- /dev/null +++ b/docs/design/2026-07-09-webshell-user-message-input-annotations.md @@ -0,0 +1,396 @@ +# WebShell user message input annotations + +## 背景 + +WebShell 的 `@` 能力已经支持在输入框中把选中的文件、扩展、MCP 资源以及 host 自定义 provider 项渲染为 chip。输入框内的 chip 来自 CodeMirror inline widget,widget 持有完整的 `WebShellComposerTag`,因此可以稳定拿到 `id`、`kind`、`label`、`value`、`serialized`、`removable` 以及 host 通过 `composerTagIcons` 注入的 icon。 + +当前 PR1 的第一版实现没有改变发送链路,只在用户消息渲染阶段从 `content` 文本中重新解析 `@...` 引用,再把能够识别的 built-in 引用渲染为 chip。这解决了部分可逆场景,例如 `@.qwen/`、`@ext:name`、`@mcp:name`,但它依赖文本猜测,无法覆盖所有真实输入。 + +review 反馈暴露了这个方向的根本问题: + +- `@Makefile`、`@LICENSE`、`@src/Makefile` 是合法文件引用,但单靠文本无法和普通 mention 或 package-like token 稳定区分。 +- `@dataset:users` 这类 custom provider 引用在发送后只剩文本,默认渲染拿不到原始 `kind`、`label`、`value` 和 icon。 +- escaped MCP resource 与尾随标点的边界只能通过启发式处理,继续补规则会让 parser 越来越复杂,仍然无法证明完整正确。 + +因此 PR1 需要扩大范围:在不改变模型收到的 prompt 文本的前提下,把 composer 已经拥有的结构化输入 metadata 沿提交、transcript、本地消息和回放链路保存下来。用户消息渲染只使用 metadata 渲染 chip;旧消息或缺失 metadata 的消息保持原始文本显示,不再尝试从纯文本猜测引用。 + +这里不能把新增字段命名为 `composerTags`。`composerTag` 是当前 `@` chip 的实现细节,但 WebShell 的用户输入里还有 `/` slash command、skill command、custom command、system command、local command 等结构化输入。新的发送 metadata 应该表达“用户输入中的结构化注解”,本期只写入 `@` reference 注解,后续可以在同一字段中加入 `/` command 注解。 + +## 目标 + +- 用户在输入框中看到的 `@` reference chip,发送后在用户消息气泡中保持一致的 chip 渲染。 +- 支持 built-in file、extension、MCP tags,包括无扩展名文件和 escaped MCP resource。 +- 支持 host 自定义 provider 的默认 chip 渲染,只要 provider 在 accepted item 中提供了 `composerTag`。 +- 保持模型侧 prompt 内容不变,daemon/model 仍然接收当前 `buildComposerPrompt(text, tags)` 生成的字符串。 +- 保持 `renderUserMessageContent` 的覆盖能力;host 如果自定义了用户消息内容,仍然可以完全接管渲染。 +- 对旧 transcript、旧 daemon、无 metadata 消息保持兼容:内容仍原样显示,只是不额外渲染 chip。 +- 为 `/` command、skill command、custom command 等后续结构化输入预留统一扩展点。 + +## 非目标 + +- 不改变 `@` provider 注册协议。 +- 不新增 skill 的 `@skill:` 支持;WebShell 当前通过 `/` 引用 skill。 +- 不把 icon URL 写入持久化 transcript。icon 继续由 `composerTagIcons` 按 `kind` 在渲染时解析。 +- 不把 metadata 传给模型,也不改变 daemon prompt 解析语义。 +- 不尝试从纯文本 100% 还原所有 custom provider 或无扩展名文件引用。 +- 本期不改变 `/` command 的渲染;只把 metadata 字段设计成可以承载 `/` command 注解。 +- 本期不补 Ctrl+Y retry 的 annotation 重建;retry 复用原始用户消息,不新增重复 user echo。 +- 本期不补 `onSubmitBefore` 失败后的 annotation 回滚;失败时 prompt 不进入发送链路,保持当前取消行为。 + +## 范围决策 + +- 本期接受同时修改 `packages/web-shell`、`packages/webui`、`packages/sdk-typescript` 和 `packages/acp-bridge`。前三者负责提交、本地 echo、transcript/message 类型和渲染;`packages/acp-bridge` 负责把 daemon user echo 写入可 replay 的 `user_message_chunk.update._meta`,否则刷新/重开 session 后无法恢复 annotation。 +- 普通发送和 queued prompt 都需要支持 annotation。queued prompt 也会在用户消息区域显示本次输入,如果不携带 metadata,会和普通发送出现不一致。 +- `renderUserMessageContent` 需要扩展入参,让 host 自定义 renderer 可以读取 `inputAnnotations`。默认 renderer 使用 metadata 渲染 chip;host renderer 仍然拥有最终覆盖权。 +- 删除从纯文本推断 `@` chip 的 fallback,避免继续维护无法完整正确的启发式 parser。 +- 本期只生成和渲染 `@` reference annotation;`/` command、skill command、custom command 只在数据结构上预留,不实现发送后 chip 渲染。 + +## 已检索到的结构化输入能力 + +当前 WebShell 输入侧至少有以下结构化能力: + +- `@` references:由 `useAtMentionMenu` 提供,包含 built-in file、extension、MCP server/resource,以及 host 通过 `atProviders` 注入的自定义 provider。接受后会生成 `WebShellComposerTag`,并由 CodeMirror inline widget 渲染 chip。 +- `/` slash commands:由 `slashCompletion.ts` 提供补全。顶层 command 来自 daemon 的 `session.available_commands`、WebShell local commands、custom commands、skill commands 和 system commands。 +- `/` subcommands:`slashCompletion.ts` 支持显式 `subcommands`、内置 subcommand tree、implicit subcommand tree。例如 `/mcp desc`、`/stats model`、`/memory show`、`/skills `。 +- command category:`commandDisplay.ts` 把 command 分为 `custom`、`skill`、`system`。`App.tsx` 会根据 `connection.skills` 把对应 command 标记为 skill category。 +- local slash commands:`localCommands.ts` 中定义了 `help`、`theme`、`language`、`model`、`mcp`、`skills`、`memory`、`context`、`agents`、`goal`、`tasks`、`extensions` 等本地命令。 +- shell mode / `!`:composer 可以以 shell mode 提交 `!${prompt}`,这是另一种用户输入语义,但不在本期渲染范围。 + +这些能力说明新增 metadata 字段应该是通用 annotation 列表,而不是只服务 `@` 的 tag 列表。 + +## 现状链路 + +### 输入框内 + +`useComposerCore` 在输入框中维护 inline tags。提交时已经能通过 `tagsOverride ?? composerTagsRef.current` 拿到完整 `WebShellComposerTag[]`。这些 tag 用于 `buildComposerPrompt(text, tags)`,最终合并进发送给 daemon 的 prompt 文本。 + +### 发送和本地 echo + +`App.tsx` 的 `sendPrompt` 只接收 `text` 和 `images`,`sessionActions.sendPrompt(text, options)` 也只发送 prompt 文本。WebShell 为了乐观显示或本地命令 echo,会调用 `store.appendLocalUserMessage(text, images)`。 + +`appendLocalUserMessage` 目前只把 `text/images` 写入 `DaemonTextTranscriptBlock`,没有携带结构化输入 metadata。 + +### 回放到消息组件 + +`transcriptBlocksToDaemonMessages` 把 transcript user block 转成 `DaemonUserMessage`,当前只保留 `content`、`images`、`timestamp` 和 `source`。`UserMessage` 只能拿到 `content/images`,因此第一版实现只能通过文本 parser 重新猜测 tag。 + +## 方案概述 + +新增一条 UI-only metadata 链路。它分成两条相邻但职责不同的路径:当前页面的乐观 echo,以及 daemon transcript 的持久化 echo。 + +```text +CodeMirror inline tags + -> submitText / submitPromptFromEditor + -> sendPrompt options + -> sessionActions.sendPrompt / sessionActions.submitPrompt options + -> A. store.appendLocalUserMessage(text, images, { inputAnnotations }) + -> 当前 tab 立即显示用户消息 chip + -> B. PromptRequest._meta.inputAnnotations + -> bridge echoPromptToSessionBus 合并到 user_message_chunk.update._meta + -> replay/load 得到同一批 session_update 事件 + -> normalizeDaemonEvent 生成 user.text.delta.meta.inputAnnotations + -> reduceDaemonTranscriptEvents 写入 DaemonTextTranscriptBlock.meta.inputAnnotations + -> transcriptBlocksToDaemonMessages + -> DaemonUserMessage.inputAnnotations + -> UserMessage default renderer +``` + +`content` 仍然是模型和 daemon 需要处理的 prompt 文本。`inputAnnotations` 只描述 UI 渲染需要的结构化输入,不参与模型输入。 + +## 数据结构 + +新增通用输入注解结构,顶层字段命名为 `inputAnnotations`: + +```ts +interface DaemonUserMessage { + id: string; + role: 'user'; + content: string; + images?: Array<{ data: string; mimeType: string }>; + source?: string; + inputAnnotations?: DaemonInputAnnotation[]; +} +``` + +`DaemonInputAnnotation` 表达“content 中某一段文本对应的结构化语义”。设计原则是只新增外层 annotation wrapper,内部 payload 尽量复用现有 `@` 和 `/` 的对象格式,避免出现一套与 `WebShellComposerTag`、`CommandInfo` 平行的新协议。本期只落 `type: 'reference'`,后续 `/` command 可以复用同一个数组继续扩展: + +```ts +interface DaemonInputReferenceAnnotation { + type: 'reference'; + start: number; + end: number; + text: string; + reference: DaemonInputReference; +} + +interface DaemonInputReference { + id: string; + kind?: string; + label?: string; + value?: string; + serialized?: string; + removable?: boolean; +} + +type DaemonInputAnnotation = DaemonInputReferenceAnnotation; +``` + +`start/end` 是相对最终 `content` 的 UTF-16 offset,与 React/CodeMirror 当前字符串处理一致。它避免后续渲染再靠 `serialized` 从 `content` 中反查位置,也为多个相同引用、相同 command、inline 文本混排留下空间。 + +本期 `@` reference payload 直接复用现有 `WebShellComposerTag`: + +```ts +interface WebShellComposerTag { + id: string; + kind?: string; + label?: string; + value?: string; + serialized?: string; + removable?: boolean; +} +``` + +未来 `/` command payload 直接复用现有 `CommandInfo`,只在 annotation 层补 `subcommandPath`: + +```ts +interface CommandInfo { + name: string; + description: string; + argumentHint?: string; + subcommands?: string[]; + source?: string; + displayCategory?: 'custom' | 'skill' | 'system'; +} +``` + +在 SDK transcript block 的 `meta` 中存储同样的 `inputAnnotations`: + +```ts +interface DaemonTextDeltaMeta { + inputAnnotations?: DaemonInputAnnotation[]; +} +``` + +实现时 SDK 包不应该 import WebShell client 类型。SDK 中定义与 `WebShellComposerTag`、`CommandInfo` 字段兼容的最小 meta 结构,WebShell adapter 再把该结构转换成 client 渲染所需类型。这样可以避免 SDK 反向依赖 WebShell,同时保持字段形态与现有 `@` / `/` 格式一致。 + +## 关键修改点 + +### 1. 提交链路携带 inputAnnotations + +调整 editor submit 的参数形态,让 `sendPrompt` 能拿到提交时的 `DaemonInputAnnotation[]`。 + +建议新增轻量 options 字段: + +```ts +interface SendPromptInputMetadata { + inputAnnotations?: DaemonInputAnnotation[]; +} +``` + +`useComposerCore.submitText()` 在生成 prompt 文本时已经知道 `tags` 和最终 `prompt`。它需要把本期 `@` tags 转换成 `reference` annotations,再调用上层 `onSubmit`: + +- `promptText`: 当前发送给 daemon 的文本,保持不变。 +- `images`: 当前图片。 +- `inputAnnotations`: 提交瞬间的结构化输入注解快照。 + +如果当前 `onSubmit` 签名不适合直接扩展,可以新增第四个 metadata 参数,避免破坏已有调用: + +```ts +onSubmit(promptText, images, commitAccepted, { inputAnnotations }); +``` + +本期 annotation 生成规则: + +- 对 `buildComposerPrompt(text, tags)` 生成的 tag prefix 计算 `start/end`。 +- 每个 tag 对应一个 `type: 'reference'` annotation。 +- `annotation.text` 使用最终 prompt 中的实际 serialized 文本。 +- `annotation.reference` 保存原有 `WebShellComposerTag` 的最小安全字段:`id/kind/label/value/serialized/removable`。 +- 不保存 icon URL;icon 仍由 `kind + composerTagIcons` 在渲染时解析。 + +如果将来 `/` command 也需要结构化渲染,可在 slash completion accept 时生成 `type: 'command'` annotation,或者在 submit 阶段根据命中的 `CommandInfo` 生成 command annotation。command payload 直接保存现有 `CommandInfo` 字段,subcommand 信息放在 annotation wrapper 的 `subcommandPath` 中。 + +### 2. 本地 transcript echo 保存 metadata + +扩展 SDK transcript store: + +```ts +appendLocalUserMessage( + text: string, + images?: Array<{ data: string; mimeType: string }>, + meta?: { inputAnnotations?: DaemonInputAnnotation[] }, +): void; +``` + +`appendLocalUserTranscriptMessage` 同步接收 `meta`: + +```ts +appendLocalUserTranscriptMessage(state, text, { images, meta }); +``` + +创建 user text block 后写入: + +```ts +if (opts.meta) { + block.meta = { ...block.meta, ...opts.meta }; +} +``` + +这条链路只保证当前前端 store 中的乐观用户消息立即带 chip。它不单独保证刷新或重新打开 session 后仍能拿到 metadata,因为刷新后的 transcript 来自 daemon replay,而不是当前 tab 内存中的 local append。 + +没有 input annotations 的本地 slash command 继续传空 metadata,不改变现有行为。 + +### 3. daemon prompt echo 持久化 metadata + +`PromptRequest` 当前已经支持 `_meta?: Record | null`。发送时把同一份 `inputAnnotations` 写入 `PromptRequest._meta.inputAnnotations`: + +```ts +const promptRequest = { + prompt: toDaemonPromptContent(text, normalizedImages), + _meta: inputAnnotations.length > 0 ? { inputAnnotations } : undefined, +}; +``` + +bridge 在 `sendPrompt` 内会把 request 交给 agent prompt,同时通过 `echoPromptToSessionBus` 发布 `user_message_chunk`。这里需要把 request `_meta.inputAnnotations` 合并到 echo 的 `update._meta` 中: + +```ts +_meta: { + ...pickUserInputEchoMeta(req._meta), + serverTimestamp, + source: 'bridge-echo', +} +``` + +`pickUserInputEchoMeta` 只保留 `inputAnnotations`,不把未知 request meta 原样写入用户消息 transcript。这样可以避免把 telemetry、requestId、retry 等非 UI 数据暴露给 `UserMessage`。 + +replay 时,`DaemonSessionProvider` 会把 `compactedReplay/liveJournal` 重新 normalize 成 UI events;`normalizeDaemonEvent` 已经会把 `user_message_chunk.update._meta` 放到 `user.text.delta.meta`;transcript reducer 已经会把 text event 的 `meta` 写入 `DaemonTextTranscriptBlock.meta`。因此只要 daemon echo 事件里带上 `inputAnnotations`,刷新和重新打开同一 session 后就能恢复 chip 渲染。 + +### 4. transcript adapter 转发 metadata + +`transcriptBlocksToDaemonMessages` 当前已经读取 user block 的 `meta.source`。在同一位置读取 `meta.inputAnnotations`,校验为数组后写到 `DaemonUserMessage.inputAnnotations`。 + +这里需要做最小结构校验,避免 transcript 中的未知 meta 影响渲染: + +- 必须是数组。 +- 每个 annotation 必须有非空 string `id/type/text`。 +- `start` 和 `end` 必须是有限数字,且满足 `0 <= start < end <= content.length`。 +- 本期只生成并渲染 `type: 'reference'` 的 annotation;后续 command annotation 可以在同一字段下扩展。 +- reference payload 按 `WebShellComposerTag` 的字段做最小净化,只接受 `id/kind/label/value/serialized` 的 string 值和 `removable` 的 boolean 值。 +- command payload 按 `CommandInfo` 的字段做最小净化,只接受 `name/description/argumentHint/source/displayCategory` 的 string 值和 `subcommands` 的 string 数组。 +- 不保留未知字段。 + +### 5. UserMessage 优先使用 inputAnnotations + +`UserMessage` props 增加: + +```ts +inputAnnotations?: DaemonInputAnnotation[]; +``` + +`renderUserMessageContent` 入参同步增加同名字段: + +```ts +renderUserMessageContent?.({ content, images, inputAnnotations }); +``` + +默认渲染逻辑改成: + +1. 如果 `inputAnnotations` 中存在合法的 `type: 'reference'` 注解,按 `start/end` 切分 `content` 并渲染 chip。 +2. 如果 metadata 缺失或没有合法 annotation,直接渲染原始文本。 +3. 如果 host 提供 `renderUserMessageContent`,继续优先使用 host renderer。 + +metadata 渲染不再从 `content` 中猜 tag 类型,也不需要按 serialized 文本查找位置。range 非法或互相重叠时忽略对应 annotation,保证不隐藏任何用户内容。 + +### 6. 删除文本 parser fallback + +`splitComposerTagContent` 不再保留。原因是旧 parser 只能靠字符串形态猜测引用类型: + +- `@Makefile` 和 `@alice` 都可能是合法文本。 +- `@dataset:users` 需要 provider metadata 才知道 label/value/icon。 +- escaped MCP resource 的尾部标点很难靠通用规则证明正确。 + +因此默认用户消息只在 annotation 存在时渲染 chip;缺失 annotation 时展示原始文本。这样 review 中的 `@Makefile` 问题不再依赖启发式,因为新消息会从 metadata 获得明确的 file tag。 + +## Custom provider 行为 + +provider 如果在 accepted item 中提供: + +```ts +composerTag: { + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + serialized: '@dataset:users', +} +``` + +发送后默认用户消息可以渲染: + +- label: `Dataset` +- value: `users` +- icon: 通过 `composerTagIcons.dataset` 解析 + +provider 如果没有提供 `composerTag`,发送后仍然只有纯文本,默认 renderer 不承诺自动识别 custom provider。host 仍然可以用 `renderUserMessageContent` 自行处理。 + +## 兼容性 + +- 旧 transcript 没有 `meta.inputAnnotations`,继续按原始文本显示。 +- 新 client 读取旧 daemon 事件时没有行为变化。 +- 旧 client 读取带 `meta.inputAnnotations` 的 transcript 时会忽略未知 meta。 +- `content` 不变,因此 daemon prompt 解析、模型输入、slash command 文本、历史 prompt 内容不受影响。 +- `renderUserMessageContent` 的优先级不变,host 自定义渲染不会被默认 chip 覆盖。 + +## 测试计划 + +### Unit tests + +- `appendLocalUserTranscriptMessage` 保存 `meta.inputAnnotations`。 +- `createDaemonTranscriptStore().appendLocalUserMessage` 能接收并保留 metadata。 +- `sessionActions.sendPrompt` 和 `sessionActions.submitPrompt` 能把 `inputAnnotations` 写入 `PromptRequest._meta`。 +- bridge `echoPromptToSessionBus` 只把 `inputAnnotations` 合并到 `user_message_chunk.update._meta`,不把未知 request meta 写入 transcript echo。 +- replay 的 `user_message_chunk.update._meta.inputAnnotations` 能经 `normalizeDaemonEvent` 和 reducer 写入 `DaemonTextTranscriptBlock.meta.inputAnnotations`。 +- `transcriptBlocksToDaemonMessages` 将 user block 的 `meta.inputAnnotations` 转成 `DaemonUserMessage.inputAnnotations`。 +- `transcriptBlocksToDaemonMessages` 过滤非法 annotation meta。 +- `UserMessage` 使用 reference annotation 渲染 `@Makefile`、`@LICENSE`、`@src/Makefile`。 +- `UserMessage` 使用 reference annotation 渲染 custom provider tag,并解析 `composerTagIcons`。 +- `UserMessage` 在 metadata 缺失时保持原始文本显示。 +- `UserMessage` 在 annotation range 非法或重叠时忽略该 annotation,不丢失原文。 +- 预留的 command annotation 类型可以被 schema 校验保留,但本期默认渲染忽略它,不影响 reference 渲染。 + +### Integration / browser verification + +- 在本地 WebShell 选择 `.qwen/`、`Makefile` 或 `LICENSE`,发送后用户消息仍显示 file chip。 +- 选择 MCP resource,发送后用户消息显示 MCP chip,resource 中的转义字符不被错误 trim。 +- 注入一个 custom provider,选择后发送,用户消息显示 custom label/value/icon。 +- 刷新页面或重新打开同一 session,用户消息 chip 仍然存在。 + +## 风险和控制 + +- 风险:跨包类型增加会扩大 PR 面积。控制方式是在 SDK 中定义最小 `DaemonInputAnnotation`,避免 SDK import WebShell client 类型。 +- 风险:metadata 与 `content` 不一致会导致渲染错位。控制方式是 UserMessage 只使用合法且不重叠的 range,非法 annotation 直接忽略,不隐藏任何用户内容。 +- 风险:持久化 custom provider 信息可能包含 host 自定义字段。控制方式是只保存 `id/kind/label/value/serialized/removable`,不保存未知字段和 icon URL。 +- 风险:PR1 范围扩大后 review 成本上升。控制方式是提交说明明确 motivation:这是为了解决纯文本 parser 无法正确还原 file/custom/MCP identity 的根因,同时保持 model-facing prompt 不变。 +- 风险:顶层 metadata 命名过窄会限制后续 `/` 能力。控制方式是使用 `inputAnnotations` 作为统一入口,本期只写入 `type: 'reference'`。 + +## 实施顺序 + +1. 在 SDK transcript 类型中增加 input annotation meta 的最小结构。 +2. 扩展 `appendLocalUserTranscriptMessage` 和 `DaemonTranscriptStore.appendLocalUserMessage`。 +3. 扩展 WebShell submit options,从 `useComposerCore` 到 `App.sendPrompt`、queued prompt submit 传递 `inputAnnotations`。 +4. 在乐观 echo 写入 `store.appendLocalUserMessage` 时带上 `inputAnnotations`。 +5. 在 daemon `PromptRequest._meta` 中写入 `inputAnnotations`,并让 bridge user echo 把它合并到 `user_message_chunk.update._meta`。 +6. 在 `transcriptBlocksToDaemonMessages` 中转发并净化 `meta.inputAnnotations`。 +7. 扩展 `DaemonUserMessage`、`MessageList` 到 `UserMessage` 的 props 链路。 +8. 扩展 `renderUserMessageContent` 入参,向 host renderer 暴露 `inputAnnotations`。 +9. `UserMessage` 默认渲染只使用 metadata;无 metadata 时原样文本显示。 +10. 补齐 unit tests 和浏览器验收截图。 + +## PR 描述要点 + +PR 描述需要说明: + +- 这不是改变模型 prompt,而是保存 WebShell 已经拥有的 UI input annotation metadata。 +- 纯文本 parser 无法可靠区分 `@Makefile`、`@alice`、`@dataset:users` 等形态,因此 metadata 是必要的。 +- 旧消息仍兼容为原始文本显示,custom provider 只有在提供 `composerTag` 时才享受默认 chip 渲染。 +- 新字段命名为 `inputAnnotations`,本期只承载 `@` reference,后续可以承载 `/` command、skill command、custom command 等结构化输入。 +- `renderUserMessageContent` 仍然是 host 的最终覆盖出口。 diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 97166e15c7f..bd21526e6e7 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -3644,6 +3644,20 @@ describe('createAcpSessionBridge', () => { const aPromise = firstUserChunk(iterA); const bPromise = firstUserChunk(iterB); + const inputAnnotations = [ + { + type: 'reference', + start: 0, + end: 7, + text: '@file/a', + reference: { + id: 'file:@file/a', + kind: 'file', + value: 'file/a', + serialized: '@file/a', + }, + }, + ]; // Client A sends the prompt with its trusted clientId. await bridge.sendPrompt( @@ -3651,6 +3665,7 @@ describe('createAcpSessionBridge', () => { { sessionId: session.sessionId, prompt: [{ type: 'text', text: 'hello from A' }], + _meta: { inputAnnotations, privateRequestId: 'hidden' }, }, undefined, { clientId: session.clientId }, @@ -3678,6 +3693,12 @@ describe('createAcpSessionBridge', () => { expect((update._meta as { source?: string })?.source).toBe( 'bridge-echo', ); + expect( + (update._meta as { inputAnnotations?: unknown })?.inputAnnotations, + ).toEqual(inputAnnotations); + expect( + (update._meta as { privateRequestId?: unknown })?.privateRequestId, + ).toBeUndefined(); } abortA.abort(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 38f910face0..c0718b483ba 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -745,6 +745,14 @@ function parseWorkspaceMemoryDreamResult( }; } +function pickUserInputEchoMeta(meta: unknown): Record { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}; + const inputAnnotations = (meta as Record)[ + 'inputAnnotations' + ]; + return Array.isArray(inputAnnotations) ? { inputAnnotations } : {}; +} + /** * Echo a user prompt to the session bus so multi-client SSE subscribers * see the input alongside the agent response. Iterates content blocks @@ -808,7 +816,11 @@ function echoPromptToSessionBus( // field permitted alongside spec fields, the SDK normalizer // reads it from `update._meta`/`data._meta`, and every other // agent-emitted session_update carries `_meta` the same way. - _meta: { serverTimestamp, source: 'bridge-echo' }, + _meta: { + ...pickUserInputEchoMeta(req._meta), + serverTimestamp, + source: 'bridge-echo', + }, }, }, ...(originatorClientId ? { originatorClientId } : {}), diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 0b5c8563470..44ee9453cce 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -130,6 +130,9 @@ export type { DaemonUserShellTranscriptBlock, DaemonPermissionTranscriptBlock, DaemonStatusTranscriptBlock, + DaemonInputAnnotation, + DaemonInputReference, + DaemonInputReferenceAnnotation, DaemonTextTranscriptBlock, DaemonTextDeltaMeta, DaemonToolPreview, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index e5f9b1a2d7c..639b24b553f 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -66,6 +66,9 @@ export type { DaemonUserShellTranscriptBlock, DaemonPermissionTranscriptBlock, DaemonStatusTranscriptBlock, + DaemonInputAnnotation, + DaemonInputReference, + DaemonInputReferenceAnnotation, DaemonTextTranscriptBlock, DaemonTextDeltaMeta, DaemonToolPreview, diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index b9326593360..0aba9aa97f3 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -5,6 +5,7 @@ */ import type { + DaemonTextDeltaMeta, DaemonTranscriptState, DaemonTranscriptStore, DaemonUiEvent, @@ -60,8 +61,9 @@ export function createDaemonTranscriptStore( appendLocalUserMessage( text: string, images?: Array<{ data: string; mimeType: string }>, + meta?: DaemonTextDeltaMeta, ) { - state = appendLocalUserTranscriptMessage(state, text, { images }); + state = appendLocalUserTranscriptMessage(state, text, { images, meta }); scheduleNotify(); }, reset(nextSeed: Partial = {}) { diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index bdc8ac1a639..57d47c773e5 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -8,6 +8,7 @@ import type { DaemonPromptCancelledTranscriptBlock, DaemonShellTranscriptBlock, DaemonStatusTranscriptBlock, + DaemonTextDeltaMeta, DaemonTextTranscriptBlock, DaemonToolTranscriptBlock, DaemonTranscriptBlock, @@ -96,11 +97,19 @@ export function appendLocalUserTranscriptMessage( text: string, opts: DaemonTranscriptReducerOptions & { images?: Array<{ data: string; mimeType: string }>; + meta?: DaemonTextDeltaMeta; } = {}, ): DaemonTranscriptState { const next = cloneTranscriptState(state, opts); finishAssistant(next); - const block = createTextBlock(next, 'user', text); + const block = createTextBlock( + next, + 'user', + text, + undefined, + undefined, + opts.meta, + ); if (opts.images && opts.images.length > 0) { (block as DaemonTextTranscriptBlock).images = [...opts.images]; } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 0b7be648165..34a11c780eb 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -94,8 +94,28 @@ export interface DaemonUiTextEvent extends DaemonUiEventBase { meta?: DaemonTextDeltaMeta; } +export interface DaemonInputReference { + id: string; + kind?: string; + label?: string; + value?: string; + serialized?: string; + removable?: boolean; +} + +export interface DaemonInputReferenceAnnotation { + type: 'reference'; + start: number; + end: number; + text: string; + reference: DaemonInputReference; +} + +export type DaemonInputAnnotation = DaemonInputReferenceAnnotation; + export interface DaemonTextDeltaMeta extends Record { qwenDiscreteMessage?: boolean; + inputAnnotations?: DaemonInputAnnotation[]; } export interface DaemonUiUserImageEvent extends DaemonUiEventBase { @@ -963,6 +983,7 @@ export interface DaemonTranscriptStore { appendLocalUserMessage( text: string, images?: Array<{ data: string; mimeType: string }>, + meta?: DaemonTextDeltaMeta, ): void; reset(seed?: Partial): void; /** diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index e8d14154011..f9e4d8ac7cc 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -64,6 +64,72 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('stores input annotations on locally appended user messages', () => { + const store = createDaemonTranscriptStore(); + const inputAnnotations = [ + { + type: 'reference' as const, + start: 6, + end: 13, + text: '@file/a', + reference: { + id: 'file:@file/a', + kind: 'file', + value: 'file/a', + serialized: '@file/a', + }, + }, + ]; + + store.appendLocalUserMessage('hello @file/a', undefined, { + inputAnnotations, + }); + + expect(store.getSnapshot().blocks[0]).toMatchObject({ + kind: 'user', + meta: { inputAnnotations }, + }); + }); + + it('stores input annotations from replayed user message chunks', () => { + const inputAnnotations = [ + { + type: 'reference' as const, + start: 0, + end: 7, + text: '@file/a', + reference: { + id: 'file:@file/a', + kind: 'file', + value: 'file/a', + serialized: '@file/a', + }, + }, + ]; + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + sessionId: 's1', + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: '@file/a' }, + _meta: { inputAnnotations }, + }, + }, + }), + { now: 2 }, + ); + + expect(state.blocks[0]).toMatchObject({ + kind: 'user', + meta: { inputAnnotations }, + }); + }); + it('preserves assistant message metadata on transcript blocks', () => { const events = normalizeDaemonEvent({ id: 10, diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index d8d0eedf596..43b3e4d0fab 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -26,6 +26,7 @@ import { } from '@qwen-code/webui/daemon-react-sdk'; import { isDaemonTurnError } from '@qwen-code/sdk/daemon'; import type { + DaemonInputAnnotation, DaemonTranscriptBlock, DaemonSessionTaskStatus, } from '@qwen-code/sdk/daemon'; @@ -280,6 +281,7 @@ interface ActiveGoalStatus { interface SendPromptOptionsWithRetry { optimisticUserMessage?: boolean; images?: PromptImage[]; + inputAnnotations?: DaemonInputAnnotation[]; retry?: boolean; clearComposerOnPromptStart?: boolean; commitComposerAccepted?: ComposerSubmitCommit; @@ -1686,6 +1688,7 @@ export function App({ opts?: { optimisticUserMessage?: boolean; retry?: boolean; + inputAnnotations?: DaemonInputAnnotation[]; clearComposerOnPromptStart?: boolean; commitComposerAccepted?: ComposerSubmitCommit; onAdmitted?: () => void; @@ -1743,6 +1746,7 @@ export function App({ } const promptOptions: SendPromptOptionsWithRetry = { images, + inputAnnotations: opts?.inputAnnotations, optimisticUserMessage: opts?.optimisticUserMessage, retry: opts?.retry, ...(opts?.onAdmitted ? { onAdmitted: opts.onAdmitted } : {}), @@ -1865,6 +1869,7 @@ export function App({ images?: PromptImage[], onComplete?: () => void, commitComposerAccepted?: ComposerSubmitCommit, + inputAnnotations?: DaemonInputAnnotation[], ) => { if (onSubmitBeforeRef.current) { onSubmitBeforeRef @@ -1873,7 +1878,12 @@ export function App({ prompt: text, }) .then(() => { - const result = rawEnqueuePrompt(text, images, onComplete); + const result = rawEnqueuePrompt( + text, + images, + onComplete, + inputAnnotations, + ); if (result !== false) { if (commitComposerAccepted) { commitComposerAccepted(); @@ -1899,7 +1909,12 @@ export function App({ }); return false; } - const result = rawEnqueuePrompt(text, images, onComplete); + const result = rawEnqueuePrompt( + text, + images, + onComplete, + inputAnnotations, + ); const sessionId = connectionRef.current.sessionId; if (sessionId && text.trim()) { dispatchSessionChangeRef.current?.({ @@ -2975,6 +2990,7 @@ export function App({ text: string, images?: PromptImage[], commitComposerAccepted?: ComposerSubmitCommit, + metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => { if (connectionRef.current.loadingTranscript) { pushToast('warning', t('editor.sessionLoading')); @@ -2993,7 +3009,11 @@ export function App({ promptText: string, promptImages: PromptImage[] | undefined, errorMessage: string, - opts?: { optimisticUserMessage?: boolean; retry?: boolean }, + opts?: { + optimisticUserMessage?: boolean; + retry?: boolean; + inputAnnotations?: DaemonInputAnnotation[]; + }, ) => { const deferComposerCommit = Boolean(onSubmitBeforeRef.current); const clearComposerOnPromptStart = @@ -3018,12 +3038,14 @@ export function App({ images, undefined, commitComposerAccepted, + metadata?.inputAnnotations, ); } return submitPromptFromEditor( text, images, 'Failed to send hidden slash command', + { inputAnnotations: metadata?.inputAnnotations }, ); } if (cmd === 'help') { @@ -3203,12 +3225,14 @@ export function App({ images, undefined, commitComposerAccepted, + metadata?.inputAnnotations, ); } return submitPromptFromEditor( text, images, 'Failed to send /model --fast', + { inputAnnotations: metadata?.inputAnnotations }, ); } if (modelArg === '--voice') { @@ -3278,6 +3302,7 @@ export function App({ prompt, images, 'Failed to send plan prompt', + { inputAnnotations: metadata?.inputAnnotations }, ); } return true; @@ -3378,12 +3403,14 @@ export function App({ images, undefined, commitComposerAccepted, + metadata?.inputAnnotations, ); } return submitPromptFromEditor( text, images, 'Failed to send /skills command', + { inputAnnotations: metadata?.inputAnnotations }, ); } else { if (echoOrDeferLocalCommand(text, images)) return true; @@ -3635,12 +3662,14 @@ export function App({ images, undefined, commitComposerAccepted, + metadata?.inputAnnotations, ); } return submitPromptFromEditor( text, images, 'Failed to send /rename command', + { inputAnnotations: metadata?.inputAnnotations }, ); } const displayName = renameArg.displayName; @@ -3830,9 +3859,17 @@ export function App({ } // Forward slash commands as prompts if (promptBlocked) { - return enqueuePrompt(text, images, undefined, commitComposerAccepted); + return enqueuePrompt( + text, + images, + undefined, + commitComposerAccepted, + metadata?.inputAnnotations, + ); } - return submitPromptFromEditor(text, images, 'Failed to send command'); + return submitPromptFromEditor(text, images, 'Failed to send command', { + inputAnnotations: metadata?.inputAnnotations, + }); } else if (text.startsWith('!')) { if (promptBlocked) { pushToast('error', t('queue.shellBlocked')); @@ -3847,9 +3884,17 @@ export function App({ return true; } else { if (promptBlocked) { - return enqueuePrompt(text, images, undefined, commitComposerAccepted); + return enqueuePrompt( + text, + images, + undefined, + commitComposerAccepted, + metadata?.inputAnnotations, + ); } - return submitPromptFromEditor(text, images, 'Failed to send message'); + return submitPromptFromEditor(text, images, 'Failed to send message', { + inputAnnotations: metadata?.inputAnnotations, + }); } }, [ @@ -3893,8 +3938,14 @@ export function App({ text: string, images?: PromptImage[], commitComposerAccepted?: ComposerSubmitCommit, + metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => { - const accepted = handleSubmit(text, images, commitComposerAccepted); + const accepted = handleSubmit( + text, + images, + commitComposerAccepted, + metadata, + ); if (accepted !== false) { resumeChatBottomFollow('smooth'); } diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 4e7482802fe..e2a18de3129 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; + export type DaemonMessageToolCallStatus = | 'pending' | 'in_progress' @@ -79,6 +81,7 @@ export interface DaemonUserMessage extends DaemonMessageMeta { role: 'user'; content: string; images?: Array<{ data: string; mimeType: string }>; + inputAnnotations?: DaemonInputAnnotation[]; source?: string; } diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index cf8cdcaf105..751204cfbc4 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -140,6 +140,36 @@ describe('transcriptBlocksToDaemonMessages', () => { }); }); + it('preserves user input annotations metadata', () => { + const inputAnnotations = [ + { + type: 'reference' as const, + start: 0, + end: 14, + text: '@dataset:users', + reference: { + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + serialized: '@dataset:users', + }, + }, + ]; + const messages = transcriptBlocksToDaemonMessages([ + textBlock('user-1', 'user', '@dataset:users', 1, false, { + meta: { inputAnnotations }, + }), + ]); + + expect(messages[0]).toMatchObject({ + id: 'user-1', + role: 'user', + content: '@dataset:users', + inputAnnotations, + }); + }); + it('hides background task notifications by metadata', () => { const messages = transcriptBlocksToDaemonMessages([ textBlock( diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index f847bbc0001..648ee9bc049 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -5,6 +5,7 @@ */ import type { + DaemonInputAnnotation, DaemonTranscriptBlock, DaemonTextTranscriptBlock, DaemonToolTranscriptBlock, @@ -36,6 +37,7 @@ type DaemonPermissionTranscriptBlock = Extract< type ExtendedDaemonTextTranscriptBlock = DaemonTextTranscriptBlock & { meta?: { source?: unknown; + inputAnnotations?: unknown; qwenDiscreteMessage?: boolean; backgroundTask?: unknown; }; @@ -224,12 +226,16 @@ export function transcriptBlocksToDaemonMessages( (textBlock as ExtendedDaemonTextTranscriptBlock).meta, ); const source = getString(meta, 'source'); + const inputAnnotations = Array.isArray(meta?.inputAnnotations) + ? (meta.inputAnnotations as DaemonInputAnnotation[]) + : undefined; const msg: DaemonUserMessage = { id: block.id, role: 'user', content: textBlock.text, timestamp: blockTime, ...(source ? { source } : {}), + ...(inputAnnotations ? { inputAnnotations } : {}), }; // Attach images if present if (textBlock.images && textBlock.images.length > 0) { diff --git a/packages/web-shell/client/components/ChatEditor.tsx b/packages/web-shell/client/components/ChatEditor.tsx index 9e58e261b42..5ec2c62d8f1 100644 --- a/packages/web-shell/client/components/ChatEditor.tsx +++ b/packages/web-shell/client/components/ChatEditor.tsx @@ -26,6 +26,7 @@ import { } from '../customization'; import { useComposerCore, + type ComposerSubmitMetadata, type EditorHandle, type SlashMenuState, getComposerTagDisplay, @@ -62,6 +63,7 @@ interface ChatEditorProps { text: string, images?: import('../adapters/promptTypes').PromptImage[], commitAccepted?: import('../hooks/useComposerCore').ComposerSubmitCommit, + metadata?: ComposerSubmitMetadata, ) => boolean | void; onCycleMode?: () => void; onToggleShortcuts?: () => void; @@ -1267,8 +1269,10 @@ export const ChatEditor = memo( } = core.searchState; const renderComposerTagContent = (tag: WebShellComposerTag) => { - const { tagLabel, tagValue, iconUrl, fallback } = - getComposerTagViewModel(tag, composerTagIcons); + const { tagLabel, tagValue, iconUrl, fallback } = getComposerTagViewModel( + tag, + composerTagIcons, + ); if (!tagLabel && !tagValue) { return {fallback}; } diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index 28123fb77db..623474323e0 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -54,6 +54,7 @@ export const MessageItem = memo(function MessageItem({ ); diff --git a/packages/web-shell/client/components/QueuedPromptDisplay.tsx b/packages/web-shell/client/components/QueuedPromptDisplay.tsx index 0b7c042bc8d..9587581923d 100644 --- a/packages/web-shell/client/components/QueuedPromptDisplay.tsx +++ b/packages/web-shell/client/components/QueuedPromptDisplay.tsx @@ -5,6 +5,7 @@ */ import type { PromptImage } from '../adapters/promptTypes'; +import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import deleteIconUrl from '../assets/icons/delete.svg'; import editIconUrl from '../assets/icons/edit.svg'; import insertIconUrl from '../assets/icons/insert.svg'; @@ -21,6 +22,7 @@ export interface QueuedPrompt { sessionId?: string; text: string; images?: PromptImage[]; + inputAnnotations?: DaemonInputAnnotation[]; onComplete?: () => void; serverPromptId?: string; serverState?: 'submitting' | 'queued' | 'running'; diff --git a/packages/web-shell/client/components/messages/UserMessage.test.tsx b/packages/web-shell/client/components/messages/UserMessage.test.tsx index fe14f443beb..11eda02a875 100644 --- a/packages/web-shell/client/components/messages/UserMessage.test.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.test.tsx @@ -29,14 +29,60 @@ function render(node: ReactNode): HTMLElement { return container; } +function referenceAnnotation( + content: string, + text: string, + reference: { + id: string; + kind?: string; + label?: string; + value?: string; + serialized?: string; + removable?: boolean; + }, +) { + const start = content.indexOf(text); + if (start < 0) { + throw new Error(`Missing annotation text: ${text}`); + } + return { + type: 'reference' as const, + start, + end: start + text.length, + text, + reference, + }; +} + +function findByTitle(container: HTMLElement, title: string): Element | null { + return ( + Array.from(container.querySelectorAll('[title]')).find( + (element) => element.getAttribute('title') === title, + ) ?? null + ); +} + describe('UserMessage', () => { it('renders content', () => { const container = render(); expect(container.textContent).toContain('hello world'); }); - it('renders file references as chips by default', () => { - const container = render(); + it('renders file references as chips from input annotations', () => { + const content = 'list @.qwen/ files'; + const container = render( + , + ); const chip = container.querySelector('[title="@.qwen/"]'); expect(chip).not.toBeNull(); @@ -44,14 +90,25 @@ describe('UserMessage', () => { expect(container.textContent).toContain('list .qwen/ files'); }); - it('uses configured composer tag icons for default chips', () => { + it('uses configured composer tag icons for annotation chips', () => { + const content = 'list @.qwen/ files'; const container = render( - + , ); const icon = container.querySelector( @@ -64,9 +121,26 @@ describe('UserMessage', () => { ); }); - it('renders extension and MCP references as chips by default', () => { + it('renders extension and MCP references as chips from input annotations', () => { + const content = '@ext:browser and @mcp:docs'; const container = render( - , + , ); expect(container.querySelector('[title="@ext:browser"]')).not.toBeNull(); @@ -81,13 +155,119 @@ describe('UserMessage', () => { expect(container.textContent).toContain('a@b.test'); }); - it('keeps custom provider references as text by default', () => { + it('keeps references as text without input annotations', () => { const container = render(); expect(container.querySelector('[title="@dataset:users"]')).toBeNull(); expect(container.textContent).toContain('open @dataset:users'); }); + it('renders extensionless file references from input annotations', () => { + const content = 'open @Makefile and @src/Makefile'; + const container = render( + , + ); + + expect(container.querySelector('[title="@Makefile"]')).not.toBeNull(); + expect(container.querySelector('[title="@src/Makefile"]')).not.toBeNull(); + expect(container.textContent).toContain('open Makefile and src/Makefile'); + }); + + it('keeps MCP resource trailing punctuation from input annotations', () => { + const serialized = '@docs\\:res\\://doc.'; + const content = `open ${serialized} now`; + const container = render( + , + ); + + expect(findByTitle(container, serialized)).not.toBeNull(); + expect(container.textContent).toContain('open docs:res://doc. now'); + }); + + it('keeps escaped trailing punctuation from input annotations', () => { + const serialized = '@path\\:'; + const content = `open ${serialized}`; + const container = render( + , + ); + + expect(findByTitle(container, serialized)).not.toBeNull(); + expect(container.textContent).toContain('open path:'); + }); + + it('renders custom provider references from input annotations', () => { + const container = render( + + + , + ); + const chip = container.querySelector('[title="@dataset:users"]'); + const icon = chip?.querySelector('[aria-hidden="true"]'); + + expect(chip).not.toBeNull(); + expect(chip?.textContent).toBe('Datasetusers'); + expect(icon?.style.getPropertyValue('--composer-tag-icon-url')).toBe( + 'url("/dataset.svg")', + ); + }); + it('renders images when provided', () => { const container = render( { const container = render( ( - {content.slice(1)} + renderUserMessageContent: ({ content, inputAnnotations }) => ( + + {content.slice(1)}:{inputAnnotations?.length ?? 0} + ), }} > - + , ); expect(container.querySelector('[data-testid="tag-chip"]')).not.toBeNull(); - expect(container.textContent).toContain('.husky/_/husky.sh xuyao'); + expect(container.textContent).toContain('.husky/_/husky.sh xuyao:1'); }); }); diff --git a/packages/web-shell/client/components/messages/UserMessage.tsx b/packages/web-shell/client/components/messages/UserMessage.tsx index 6a2af25e65b..d9a37d91617 100644 --- a/packages/web-shell/client/components/messages/UserMessage.tsx +++ b/packages/web-shell/client/components/messages/UserMessage.tsx @@ -10,8 +10,9 @@ import { } from 'react'; import { getComposerTagViewModel, - splitComposerTagContent, + splitComposerTagContentByAnnotations, } from '../../utils/composerTag'; +import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import { isSafeImageSrc } from './Markdown'; import { useWebShellCustomization, @@ -31,6 +32,7 @@ interface UserMessageImage { interface UserMessageProps { content: string; images?: UserMessageImage[]; + inputAnnotations?: readonly DaemonInputAnnotation[]; isLocateFlashing?: boolean; } @@ -63,13 +65,18 @@ function UserMessageReferenceChip({ function DefaultUserMessageContent({ composerTagIcons, content, + inputAnnotations, }: { composerTagIcons?: WebShellComposerTagIconMap; content: string; + inputAnnotations?: readonly DaemonInputAnnotation[]; }) { - // The default user renderer upgrades serialized @ references to chips while - // still allowing hosts to replace message rendering entirely. - const segments = useMemo(() => splitComposerTagContent(content), [content]); + // Submit-time annotations are the source of truth for reference chips. + // Unannotated serialized text stays plain text. + const segments = useMemo( + () => splitComposerTagContentByAnnotations(content, inputAnnotations), + [content, inputAnnotations], + ); return ( <> {segments.map((segment, index) => @@ -90,6 +97,7 @@ function DefaultUserMessageContent({ export const UserMessage = memo(function UserMessage({ content, images, + inputAnnotations, isLocateFlashing = false, }: UserMessageProps) { const { t } = useI18n(); @@ -100,13 +108,20 @@ export const UserMessage = memo(function UserMessage({ const [heightOverflowing, setHeightOverflowing] = useState(false); const renderedContent = useMemo( () => - renderUserMessageContent?.({ content, images }) ?? ( + renderUserMessageContent?.({ content, images, inputAnnotations }) ?? ( ), - [composerTagIcons, content, images, renderUserMessageContent], + [ + composerTagIcons, + content, + images, + inputAnnotations, + renderUserMessageContent, + ], ); const measureOverflow = useCallback(() => { diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index b8d7141afc9..a4c98e74bcf 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -5,6 +5,7 @@ import { type ReactNode, } from 'react'; import type { Components, Options } from 'react-markdown'; +import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import type { DaemonStreamingState } from '@qwen-code/webui/daemon-react-sdk'; import type { ACPToolCall } from './adapters/types'; import type { WelcomeHeaderProps } from './components/WelcomeHeader'; @@ -93,6 +94,7 @@ export type WelcomeFooterRenderer = (props: WelcomeHeaderProps) => ReactNode; export interface UserMessageContentRenderInfo { content: string; images?: readonly { data: string; mimeType: string }[]; + inputAnnotations?: readonly DaemonInputAnnotation[]; } export type UserMessageContentRenderer = ( diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index ea0bc57137c..2402717f028 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -54,9 +54,12 @@ import { import { isEditableTarget } from '../utils/dom'; import { cssUrlValue } from '../utils/cssUrlVar'; import { + createInputAnnotationsFromComposerTags, getComposerTagIconUrl, + getComposerTagSerialized, getComposerTagViewModel, } from '../utils/composerTag'; +import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import type { WebShellComposerApi, WebShellComposerInput, @@ -480,9 +483,7 @@ export function expandLargePastePlaceholders( // ---- Tag serialization (shared) ---- export function serializeComposerTag(tag: WebShellComposerTag): string { - return ( - tag.serialized?.trim() || tag.value?.trim() || tag.label?.trim() || tag.id - ); + return getComposerTagSerialized(tag); } function serializeComposerTags(tags: readonly WebShellComposerTag[]): string { @@ -796,11 +797,16 @@ function createFollowupGhostExtension(suggestion: string | null) { export type ComposerSubmitCommit = () => void; +export interface ComposerSubmitMetadata { + inputAnnotations?: DaemonInputAnnotation[]; +} + export interface UseComposerCoreOptions { onSubmit: ( text: string, images?: PromptImage[], commitAccepted?: ComposerSubmitCommit, + metadata?: ComposerSubmitMetadata, ) => boolean | void; onCycleMode?: () => void; onToggleShortcuts?: () => void; @@ -1457,6 +1463,11 @@ export function useComposerCore( const prompt = buildComposerPrompt(text, tags); const images = pastedImagesRef.current; const isShellMode = shellModeRef.current; + const promptText = isShellMode ? `!${prompt}` : prompt; + const inputAnnotations = createInputAnnotationsFromComposerTags( + promptText, + tags, + ); let committed = false; const commitAccepted = () => { if (committed) return; @@ -1484,9 +1495,10 @@ export function useComposerCore( }); }; const accepted = onSubmitRef.current( - isShellMode ? `!${prompt}` : prompt, + promptText, images.length > 0 ? [...images] : undefined, commitAccepted, + inputAnnotations.length > 0 ? { inputAnnotations } : undefined, ); if (accepted === false) return true; commitAccepted(); @@ -2528,11 +2540,7 @@ export function useComposerCore( if (!view) return; const inlineTags = getInlineComposerTags(view); if (input?.tagPlacement === 'inline') { - submitTextRef.current( - view, - buildComposerPrompt(input.text ?? '', input.tags ?? inlineTags), - [], - ); + submitTextRef.current(view, input.text ?? '', input.tags ?? inlineTags); return; } if ( @@ -2540,11 +2548,7 @@ export function useComposerCore( input.tags === undefined && inlineTags.length > 0 ) { - submitTextRef.current( - view, - buildComposerPrompt(input.text, inlineTags), - [], - ); + submitTextRef.current(view, input.text, inlineTags); return; } submitTextRef.current( diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index 38d49514592..15b8b1c0516 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -23,6 +23,7 @@ import { type DaemonStreamingState, } from '@qwen-code/webui/daemon-react-sdk'; import type { + DaemonInputAnnotation, DaemonPendingPromptSummary, DaemonTranscriptStore, } from '@qwen-code/sdk/daemon'; @@ -74,7 +75,9 @@ function areQueuedPromptsEqual( prompt.serverState === other.serverState && prompt.isEditing === other.isEditing && prompt.isRemoving === other.isRemoving && - (prompt.images?.length ?? 0) === (other.images?.length ?? 0) + (prompt.images?.length ?? 0) === (other.images?.length ?? 0) && + (prompt.inputAnnotations?.length ?? 0) === + (other.inputAnnotations?.length ?? 0) ); }); } @@ -96,6 +99,7 @@ export interface UseQueuedPromptsResult { text: string, images?: PromptImage[], onComplete?: () => void, + inputAnnotations?: DaemonInputAnnotation[], ) => boolean; removeQueuedPrompt: (id: number) => void; insertQueuedPrompt: (id: number) => Promise; @@ -359,7 +363,13 @@ export function useQueuedPrompts({ const text = prompt?.text ?? ''; if (text) { displayedServerPromptIdsRef.current.add(promptId); - store.appendLocalUserMessage(text, toStoreImages(prompt?.images)); + store.appendLocalUserMessage( + text, + toStoreImages(prompt?.images), + prompt?.inputAnnotations?.length + ? { inputAnnotations: prompt.inputAnnotations } + : undefined, + ); } } void refreshPendingPrompts(); @@ -415,7 +425,12 @@ export function useQueuedPrompts({ ); const enqueuePrompt = useCallback( - (text: string, images?: PromptImage[], onComplete?: () => void) => { + ( + text: string, + images?: PromptImage[], + onComplete?: () => void, + inputAnnotations?: DaemonInputAnnotation[], + ) => { const trimmed = text.trim(); if (!trimmed) return true; const localId = nextQueuedPromptIdRef.current++; @@ -428,6 +443,7 @@ export function useQueuedPrompts({ sessionId: targetSessionId, text: trimmed, images: queuedImages, + inputAnnotations, onComplete, serverState: 'submitting', }; @@ -437,6 +453,7 @@ export function useQueuedPrompts({ sessionActions .submitPrompt(trimmed, { images, + inputAnnotations, optimisticUserMessage: false, sessionId: targetSessionId, signal: submitAbort.signal, @@ -462,6 +479,7 @@ export function useQueuedPrompts({ store.appendLocalUserMessage( trimmed, toStoreImages(queuedImages), + inputAnnotations?.length ? { inputAnnotations } : undefined, ); } const next = queuedPromptsRef.current.filter( diff --git a/packages/web-shell/client/utils/composerTag.test.ts b/packages/web-shell/client/utils/composerTag.test.ts index c21541623ea..40937c27f19 100644 --- a/packages/web-shell/client/utils/composerTag.test.ts +++ b/packages/web-shell/client/utils/composerTag.test.ts @@ -1,10 +1,36 @@ import { describe, expect, it } from 'vitest'; import { + createInputAnnotationsFromComposerTags, getComposerTagIconUrl, getComposerTagViewModel, - splitComposerTagContent, + splitComposerTagContentByAnnotations, } from './composerTag'; +function referenceAnnotation( + content: string, + text: string, + reference: { + id: string; + kind?: string; + label?: string; + value?: string; + serialized?: string; + removable?: boolean; + }, +) { + const start = content.indexOf(text); + if (start < 0) { + throw new Error(`Missing annotation text: ${text}`); + } + return { + type: 'reference' as const, + start, + end: start + text.length, + text, + reference, + }; +} + describe('composer tag icon URLs', () => { it('uses registered icons for custom tag kinds', () => { expect(getComposerTagIconUrl('table', { table: '/icons/table.svg' })).toBe( @@ -27,256 +53,321 @@ describe('composer tag icon URLs', () => { }); }); -describe('splitComposerTagContent', () => { - it('returns text for empty content and bare @', () => { - expect(splitComposerTagContent('')).toEqual([{ type: 'text', text: '' }]); - expect(splitComposerTagContent('@')).toEqual([{ type: 'text', text: '@' }]); - expect(splitComposerTagContent('hello @ world')).toEqual([ - { type: 'text', text: 'hello @ world' }, - ]); - expect(splitComposerTagContent('end @')).toEqual([ - { type: 'text', text: 'end @' }, - ]); +describe('getComposerTagViewModel', () => { + it('returns display fields for custom tags', () => { + expect( + getComposerTagViewModel({ + id: 'custom:1', + label: ' Dataset ', + value: ' users.csv ', + }), + ).toEqual({ + tagLabel: 'Dataset', + tagValue: 'users.csv', + fallback: 'custom:1', + iconUrl: undefined, + }); + }); + + it('hides labels for built-in tag kinds and resolves icons', () => { + const model = getComposerTagViewModel({ + id: 'file:@.qwen/', + kind: 'file', + label: 'File', + value: '.qwen/', + }); + + expect(model.tagLabel).toBe(''); + expect(model.tagValue).toBe('.qwen/'); + expect(model.fallback).toBe('file:@.qwen/'); + expect(model.iconUrl).toBeTruthy(); + }); + + it('uses custom icon maps when provided', () => { + expect( + getComposerTagViewModel( + { + id: 'file:@src/index.ts', + kind: 'file', + value: 'src/index.ts', + }, + { file: '/custom-file.svg' }, + ).iconUrl, + ).toBe('/custom-file.svg'); }); - it('splits file references into text and reference segments', () => { - expect(splitComposerTagContent('list @.qwen/ files')).toEqual([ - { type: 'text', text: 'list ' }, + it('uses fallback when tag has no display text', () => { + expect(getComposerTagViewModel({ id: 'tag-id' })).toEqual({ + tagLabel: '', + tagValue: '', + fallback: 'tag-id', + iconUrl: undefined, + }); + }); + + it('keeps labels for custom tag kinds', () => { + expect( + getComposerTagViewModel({ + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + }), + ).toEqual({ + tagLabel: 'Dataset', + tagValue: 'users', + fallback: 'dataset:users', + iconUrl: undefined, + }); + }); +}); + +describe('composer tag input annotations', () => { + it('creates reference annotations using ranges from final prompt text', () => { + expect( + createInputAnnotationsFromComposerTags('@dataset:users\n\nshow rows', [ + { + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + serialized: '@dataset:users', + }, + ]), + ).toEqual([ { type: 'reference', - tag: { - id: 'file:@.qwen/', - kind: 'file', - value: '.qwen/', - serialized: '@.qwen/', + start: 0, + end: 14, + text: '@dataset:users', + reference: { + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + serialized: '@dataset:users', }, }, - { type: 'text', text: ' files' }, ]); }); - it('keeps adjacent non-boundary references as text', () => { - expect(splitComposerTagContent('@.qwen/@src/index.ts')).toEqual([ + it('creates annotations for extensionless file references', () => { + expect( + createInputAnnotationsFromComposerTags( + '@Makefile @LICENSE @src/Makefile', + [ + { + id: 'file:@Makefile', + kind: 'file', + value: 'Makefile', + serialized: '@Makefile', + }, + { + id: 'file:@LICENSE', + kind: 'file', + value: 'LICENSE', + serialized: '@LICENSE', + }, + { + id: 'file:@src/Makefile', + kind: 'file', + value: 'src/Makefile', + serialized: '@src/Makefile', + }, + ], + ), + ).toEqual([ { type: 'reference', - tag: { - id: 'file:@.qwen/', + start: 0, + end: 9, + text: '@Makefile', + reference: { + id: 'file:@Makefile', kind: 'file', - value: '.qwen/', - serialized: '@.qwen/', + value: 'Makefile', + serialized: '@Makefile', }, }, - { type: 'text', text: '@src/index.ts' }, - ]); - }); - - it('creates extension and MCP reference tags', () => { - expect(splitComposerTagContent('@ext:browser and @mcp:docs')).toEqual([ { type: 'reference', - tag: { - id: 'extension:@ext:browser', - kind: 'extension', - value: 'browser', - serialized: '@ext:browser', + start: 10, + end: 18, + text: '@LICENSE', + reference: { + id: 'file:@LICENSE', + kind: 'file', + value: 'LICENSE', + serialized: '@LICENSE', }, }, - { type: 'text', text: ' and ' }, { type: 'reference', - tag: { - id: 'mcp:@mcp:docs', - kind: 'mcp', - value: 'docs', - serialized: '@mcp:docs', + start: 19, + end: 32, + text: '@src/Makefile', + reference: { + id: 'file:@src/Makefile', + kind: 'file', + value: 'src/Makefile', + serialized: '@src/Makefile', }, }, ]); }); - it('creates MCP resource tags from escaped server resource references', () => { - expect(splitComposerTagContent('open @docs\\:res\\://doc')).toEqual([ + it('uses annotations for custom provider references', () => { + expect( + splitComposerTagContentByAnnotations('open @dataset:users now', [ + { + type: 'reference', + start: 5, + end: 19, + text: '@dataset:users', + reference: { + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + serialized: '@dataset:users', + }, + }, + ]), + ).toEqual([ { type: 'text', text: 'open ' }, { type: 'reference', tag: { - id: 'mcp:@docs\\:res\\://doc', - kind: 'mcp', - value: 'docs:res://doc', - serialized: '@docs\\:res\\://doc', + id: 'dataset:users', + kind: 'dataset', + label: 'Dataset', + value: 'users', + serialized: '@dataset:users', }, }, + { type: 'text', text: ' now' }, ]); }); - it('does not split inline email-like text', () => { - expect(splitComposerTagContent('mail a@b.test')).toEqual([ - { type: 'text', text: 'mail a@b.test' }, - ]); - }); + it('uses annotations for extensionless file references', () => { + const content = 'open @Makefile and @src/Makefile'; - it('keeps package and handle-like references as text', () => { expect( - splitComposerTagContent('install @types/node and ask @alice'), - ).toEqual([{ type: 'text', text: 'install @types/node and ask @alice' }]); - }); - - it('does not split references after non-boundary characters', () => { - expect(splitComposerTagContent('prefix/@.qwen/')).toEqual([ - { type: 'text', text: 'prefix/@.qwen/' }, - ]); - }); - - it('keeps custom provider-prefixed references as text', () => { - expect(splitComposerTagContent('open @dataset:users')).toEqual([ - { type: 'text', text: 'open @dataset:users' }, - ]); - }); - - it('keeps short Windows drive references as file tags', () => { - expect(splitComposerTagContent('open @C:file')).toEqual([ + splitComposerTagContentByAnnotations(content, [ + referenceAnnotation(content, '@Makefile', { + id: 'file:@Makefile', + kind: 'file', + value: 'Makefile', + serialized: '@Makefile', + }), + referenceAnnotation(content, '@src/Makefile', { + id: 'file:@src/Makefile', + kind: 'file', + value: 'src/Makefile', + serialized: '@src/Makefile', + }), + ]), + ).toEqual([ { type: 'text', text: 'open ' }, { type: 'reference', tag: { - id: 'file:@C:file', + id: 'file:@Makefile', kind: 'file', - value: 'C:file', - serialized: '@C:file', + value: 'Makefile', + serialized: '@Makefile', }, }, - ]); - }); - - it('keeps Windows drive references as file tags', () => { - expect(splitComposerTagContent('open @C:/Users/name/file.ts')).toEqual([ - { type: 'text', text: 'open ' }, + { type: 'text', text: ' and ' }, { type: 'reference', tag: { - id: 'file:@C:/Users/name/file.ts', + id: 'file:@src/Makefile', kind: 'file', - value: 'C:/Users/name/file.ts', - serialized: '@C:/Users/name/file.ts', + value: 'src/Makefile', + serialized: '@src/Makefile', }, }, ]); }); - it('unescapes reference display text', () => { + it('keeps MCP resource trailing punctuation from annotations', () => { + const serialized = '@docs\\:res\\://doc.'; + const content = `open ${serialized} now`; + expect( - splitComposerTagContent('open @path/file\\ with\\ spaces.txt'), + splitComposerTagContentByAnnotations(content, [ + referenceAnnotation(content, serialized, { + id: `mcp:${serialized}`, + kind: 'mcp', + value: 'docs:res://doc.', + serialized, + }), + ]), ).toEqual([ { type: 'text', text: 'open ' }, { type: 'reference', tag: { - id: 'file:@path/file\\ with\\ spaces.txt', - kind: 'file', - value: 'path/file with spaces.txt', - serialized: '@path/file\\ with\\ spaces.txt', + id: 'mcp:@docs\\:res\\://doc.', + kind: 'mcp', + value: 'docs:res://doc.', + serialized: '@docs\\:res\\://doc.', }, }, + { type: 'text', text: ' now' }, ]); }); - it('advances over escaped emoji code points', () => { - expect(splitComposerTagContent('open @path/\\😀.txt')).toEqual([ + it('keeps escaped trailing punctuation from annotations', () => { + const serialized = '@path\\:'; + const content = `open ${serialized}`; + + expect( + splitComposerTagContentByAnnotations(content, [ + referenceAnnotation(content, serialized, { + id: `file:${serialized}`, + kind: 'file', + value: 'path:', + serialized, + }), + ]), + ).toEqual([ { type: 'text', text: 'open ' }, { type: 'reference', tag: { - id: 'file:@path/\\😀.txt', + id: 'file:@path\\:', kind: 'file', - value: 'path/😀.txt', - serialized: '@path/\\😀.txt', + value: 'path:', + serialized: '@path\\:', }, }, ]); }); - it('leaves trailing sentence punctuation outside chips', () => { - expect(splitComposerTagContent('See @file.ts.')).toEqual([ - { type: 'text', text: 'See ' }, - { - type: 'reference', - tag: { - id: 'file:@file.ts', - kind: 'file', - value: 'file.ts', - serialized: '@file.ts', - }, - }, - { type: 'text', text: '.' }, + it('leaves unannotated references as text', () => { + expect(splitComposerTagContentByAnnotations('list @.qwen/ files')).toEqual([ + { type: 'text', text: 'list @.qwen/ files' }, ]); }); -}); - -describe('getComposerTagViewModel', () => { - it('returns display fields for custom tags', () => { - expect( - getComposerTagViewModel({ - id: 'custom:1', - label: ' Dataset ', - value: ' users.csv ', - }), - ).toEqual({ - tagLabel: 'Dataset', - tagValue: 'users.csv', - fallback: 'custom:1', - iconUrl: undefined, - }); - }); - it('hides labels for built-in tag kinds and resolves icons', () => { - const model = getComposerTagViewModel({ - id: 'file:@.qwen/', - kind: 'file', - label: 'File', - value: '.qwen/', - }); - - expect(model.tagLabel).toBe(''); - expect(model.tagValue).toBe('.qwen/'); - expect(model.fallback).toBe('file:@.qwen/'); - expect(model.iconUrl).toBeTruthy(); - }); - - it('uses custom icon maps when provided', () => { + it('leaves invalid annotation ranges as text', () => { expect( - getComposerTagViewModel( + splitComposerTagContentByAnnotations('list @.qwen/ files', [ { - id: 'file:@src/index.ts', - kind: 'file', - value: 'src/index.ts', + type: 'reference', + start: 5, + end: 12, + text: '@wrong/', + reference: { + id: 'file:@wrong/', + kind: 'file', + value: 'wrong/', + serialized: '@wrong/', + }, }, - { file: '/custom-file.svg' }, - ).iconUrl, - ).toBe('/custom-file.svg'); - }); - - it('uses fallback when tag has no display text', () => { - expect(getComposerTagViewModel({ id: 'tag-id' })).toEqual({ - tagLabel: '', - tagValue: '', - fallback: 'tag-id', - iconUrl: undefined, - }); - }); - - it('keeps labels for custom tag kinds', () => { - expect( - getComposerTagViewModel({ - id: 'dataset:users', - kind: 'dataset', - label: 'Dataset', - value: 'users', - }), - ).toEqual({ - tagLabel: 'Dataset', - tagValue: 'users', - fallback: 'dataset:users', - iconUrl: undefined, - }); + ]), + ).toEqual([{ type: 'text', text: 'list @.qwen/ files' }]); }); }); diff --git a/packages/web-shell/client/utils/composerTag.ts b/packages/web-shell/client/utils/composerTag.ts index 53f8b9d1d09..c6ac1d3a444 100644 --- a/packages/web-shell/client/utils/composerTag.ts +++ b/packages/web-shell/client/utils/composerTag.ts @@ -2,6 +2,7 @@ import extensionIconUrl from '../assets/icons/at-extension.svg'; import fileIconUrl from '../assets/icons/at-file.svg'; import mcpIconUrl from '../assets/icons/at-mcp.svg'; import skillIconUrl from '../assets/icons/at-skill.svg'; +import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import type { WebShellBuiltinComposerTagKind, WebShellComposerTag, @@ -29,9 +30,6 @@ const builtinTagIconUrls: Record = { skill: skillIconUrl, }; -const AT_REFERENCE_CHAR_RE = /[\p{L}\p{N}_./:-]/u; -const WINDOWS_DRIVE_REFERENCE_RE = /^[A-Za-z]:/; - function getOwnIconUrl( iconUrls: WebShellComposerTagIconMap | undefined, kind: string, @@ -43,114 +41,12 @@ function getOwnIconUrl( return typeof iconUrl === 'string' ? iconUrl : undefined; } -function isAtReferenceBoundary(char: string | undefined): boolean { - return char === undefined || /[\s([{'"]/.test(char); -} - -function readAtReferenceEnd(content: string, start: number): number { - let index = start + 1; - while (index < content.length) { - const char = content[index]; - if (char === '\\' && index + 1 < content.length) { - const escapedCodePoint = content.codePointAt(index + 1); - index += 1 + (escapedCodePoint && escapedCodePoint > 0xffff ? 2 : 1); - continue; - } - if (!AT_REFERENCE_CHAR_RE.test(char)) break; - index += 1; - } - return index; -} - -function trimReferenceTrailingPunctuation( - content: string, - end: number, -): number { - let nextEnd = end; - while (nextEnd > 0) { - const last = content[nextEnd - 1]; - if ( - (last !== '.' && last !== ':') || - !isAtReferenceBoundary(content[nextEnd]) - ) { - break; - } - nextEnd -= 1; - } - return nextEnd; -} - -function getReferenceDisplay(raw: string, prefix: string) { - const withoutAt = raw.slice(1); - const value = prefix ? withoutAt.slice(prefix.length) : withoutAt; - return value.replace(/\\(.)/gu, '$1') || raw; -} - -// MCP resources are serialized as @server\:uri, with the server/URI delimiter -// escaped by the composer. That is the only provider-prefixed form this parser -// can recover without persisted composer tag metadata. -function isEscapedMcpResourceReference(raw: string): boolean { - const value = raw.slice(1); - const escapedDelimiterIndex = value.indexOf('\\:'); - if (escapedDelimiterIndex <= 0) return false; - const unescaped = getReferenceDisplay(raw, ''); - const delimiterIndex = unescaped.indexOf(':'); - if (delimiterIndex <= 0) return false; - return unescaped.slice(delimiterIndex + 1).includes(':'); -} - -// User messages persist only text, so ambiguous mentions like @alice or -// @types/node must remain text. Only chipify file references with path cues. -function isBuiltInFileReference(raw: string): boolean { - const value = getReferenceDisplay(raw, ''); +export function getComposerTagSerialized(tag: WebShellComposerTag): string { return ( - value.startsWith('./') || - value.startsWith('../') || - value.startsWith('/') || - value.endsWith('/') || - value.includes('.') || - WINDOWS_DRIVE_REFERENCE_RE.test(value) + tag.serialized?.trim() || tag.value?.trim() || tag.label?.trim() || tag.id ); } -function createComposerTagFromReference( - raw: string, -): WebShellComposerTag | null { - if (raw.startsWith('@ext:')) { - return { - id: `extension:${raw}`, - kind: 'extension', - value: getReferenceDisplay(raw, 'ext:'), - serialized: raw, - }; - } - if (raw.startsWith('@mcp:')) { - return { - id: `mcp:${raw}`, - kind: 'mcp', - value: getReferenceDisplay(raw, 'mcp:'), - serialized: raw, - }; - } - if (isEscapedMcpResourceReference(raw)) { - return { - id: `mcp:${raw}`, - kind: 'mcp', - value: getReferenceDisplay(raw, ''), - serialized: raw, - }; - } - if (isBuiltInFileReference(raw)) { - return { - id: `file:${raw}`, - kind: 'file', - value: getReferenceDisplay(raw, ''), - serialized: raw, - }; - } - return null; -} - export function getComposerTagIconUrl( kind: WebShellComposerTagKind | undefined, customIconUrls?: WebShellComposerTagIconMap, @@ -162,34 +58,76 @@ export function getComposerTagIconUrl( ); } -// Sent user messages persist serialized prompt text, so reference chips must be -// reconstructed from @ references when rendering the transcript. -export function splitComposerTagContent( +export function createInputAnnotationsFromComposerTags( content: string, + tags: readonly WebShellComposerTag[], +): DaemonInputAnnotation[] { + const annotations: DaemonInputAnnotation[] = []; + let cursor = 0; + for (const tag of tags) { + const serialized = getComposerTagSerialized(tag); + if (!serialized) continue; + const start = content.indexOf(serialized, cursor); + if (start < 0) continue; + const end = start + serialized.length; + annotations.push({ + type: 'reference', + start, + end, + text: serialized, + reference: { + id: tag.id, + ...(tag.kind ? { kind: tag.kind } : {}), + ...(tag.label ? { label: tag.label } : {}), + ...(tag.value ? { value: tag.value } : {}), + ...(tag.serialized ? { serialized: tag.serialized } : {}), + ...(tag.removable !== undefined ? { removable: tag.removable } : {}), + }, + }); + cursor = end; + } + return annotations; +} + +// User messages render chips only from submit-time annotations. Without that +// metadata, the serialized text remains plain text instead of being guessed. +export function splitComposerTagContentByAnnotations( + content: string, + inputAnnotations?: readonly DaemonInputAnnotation[], ): ComposerTagContentSegment[] { + if (!inputAnnotations || inputAnnotations.length === 0) { + return [{ type: 'text', text: content }]; + } const segments: ComposerTagContentSegment[] = []; let cursor = 0; - for (let index = 0; index < content.length; index += 1) { - if (content[index] !== '@' || !isAtReferenceBoundary(content[index - 1])) { + for (const annotation of inputAnnotations) { + if (annotation.type !== 'reference') continue; + const { start, end, reference, text } = annotation; + if ( + start < cursor || + end <= start || + end > content.length || + content.slice(start, end) !== text + ) { continue; } - const end = readAtReferenceEnd(content, index); - if (end === index + 1) continue; - const referenceEnd = trimReferenceTrailingPunctuation(content, end); - if (referenceEnd === index + 1) continue; - const tag = createComposerTagFromReference( - content.slice(index, referenceEnd), - ); - if (!tag) continue; - if (cursor < index) { - segments.push({ type: 'text', text: content.slice(cursor, index) }); + if (cursor < start) { + segments.push({ type: 'text', text: content.slice(cursor, start) }); } segments.push({ type: 'reference', - tag, + tag: { + id: reference.id, + ...(reference.kind ? { kind: reference.kind } : {}), + ...(reference.label ? { label: reference.label } : {}), + ...(reference.value ? { value: reference.value } : {}), + serialized: reference.serialized ?? text, + ...(reference.removable !== undefined + ? { removable: reference.removable } + : {}), + }, }); - cursor = referenceEnd; - index = referenceEnd - 1; + cursor = end; } if (cursor < content.length) { segments.push({ type: 'text', text: content.slice(cursor) }); diff --git a/packages/webui/src/daemon/session/actions.ts b/packages/webui/src/daemon/session/actions.ts index b028af0a3ba..600c571e00b 100644 --- a/packages/webui/src/daemon/session/actions.ts +++ b/packages/webui/src/daemon/session/actions.ts @@ -285,12 +285,23 @@ export function createDaemonSessionActions({ mimeType: img.mimeType || img.mediaType || img.media_type || 'image/*', })); + const inputAnnotations = + options?.inputAnnotations && options.inputAnnotations.length > 0 + ? options.inputAnnotations + : undefined; if (options?.optimisticUserMessage !== false) { - store.appendLocalUserMessage(text, normalizedImages); + store.appendLocalUserMessage( + text, + normalizedImages, + inputAnnotations ? { inputAnnotations } : undefined, + ); } const promptRequest: Record = { prompt: toDaemonPromptContent(text, normalizedImages), }; + if (inputAnnotations) { + promptRequest['_meta'] = { inputAnnotations }; + } if (options?.retry) { promptRequest['retry'] = true; } @@ -357,12 +368,23 @@ export function createDaemonSessionActions({ data: img.data, mimeType: img.mimeType || img.mediaType || img.media_type || 'image/*', })); + const inputAnnotations = + options?.inputAnnotations && options.inputAnnotations.length > 0 + ? options.inputAnnotations + : undefined; if (options?.optimisticUserMessage !== false) { - store.appendLocalUserMessage(text, normalizedImages); + store.appendLocalUserMessage( + text, + normalizedImages, + inputAnnotations ? { inputAnnotations } : undefined, + ); } const promptRequest: Record = { prompt: toDaemonPromptContent(text, normalizedImages), }; + if (inputAnnotations) { + promptRequest['_meta'] = { inputAnnotations }; + } if (options?.retry) { promptRequest['retry'] = true; } diff --git a/packages/webui/src/daemon/session/types.ts b/packages/webui/src/daemon/session/types.ts index cf42c1413a8..35e46d91d32 100644 --- a/packages/webui/src/daemon/session/types.ts +++ b/packages/webui/src/daemon/session/types.ts @@ -12,6 +12,7 @@ import type { DaemonApprovalModeResult, DaemonAvailableCommand, DaemonForkSessionResult, + DaemonInputAnnotation, DaemonSessionBtwResult, DaemonMidTurnMessageResult, DaemonPendingPromptsResult, @@ -232,6 +233,7 @@ export interface DaemonCommandInfo { export interface SendPromptOptions { optimisticUserMessage?: boolean; images?: DaemonPromptImage[]; + inputAnnotations?: DaemonInputAnnotation[]; /** * When true, the daemon strips orphaned user entries from the chat * history before re-sending, and skips recording a duplicate user From 5bb47de7ba2be0fb3ea09c1533331d140c380aec Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Fri, 10 Jul 2026 15:20:39 +0800 Subject: [PATCH 07/12] fix(web-shell): include inline tags in input annotations --- .../web-shell/client/hooks/useComposerCore.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/web-shell/client/hooks/useComposerCore.ts b/packages/web-shell/client/hooks/useComposerCore.ts index 2402717f028..b2a4b185124 100644 --- a/packages/web-shell/client/hooks/useComposerCore.ts +++ b/packages/web-shell/client/hooks/useComposerCore.ts @@ -686,12 +686,11 @@ const inlineComposerTagField = StateField.define({ export function getInlineComposerTags(view: EditorView): WebShellComposerTag[] { const tags: WebShellComposerTag[] = []; - view.state - .field(inlineComposerTagField) - .between(0, view.state.doc.length, (_from, _to, value) => { - const tag = (value.spec as Partial).tag; - if (tag) tags.push(toPublicComposerTag(tag)); - }); + const inlineTags = view.state.field(inlineComposerTagField, false); + inlineTags?.between(0, view.state.doc.length, (_from, _to, value) => { + const tag = (value.spec as Partial).tag; + if (tag) tags.push(toPublicComposerTag(tag)); + }); return tags; } @@ -1455,7 +1454,8 @@ export function useComposerCore( : null; const rawText = (textOverride ?? followupCompletion ?? editorText).trim(); const tags = tagsOverride ?? composerTagsRef.current; - if (!rawText && tags.length === 0) return true; + const inlineTags = tagsOverride ? [] : getInlineComposerTags(view); + if (!rawText && tags.length === 0 && inlineTags.length === 0) return true; const text = expandLargePastePlaceholders( pendingPastesRef.current, rawText, @@ -1466,7 +1466,7 @@ export function useComposerCore( const promptText = isShellMode ? `!${prompt}` : prompt; const inputAnnotations = createInputAnnotationsFromComposerTags( promptText, - tags, + [...tags, ...inlineTags], ); let committed = false; const commitAccepted = () => { From a621f03974dc3ba3730af17b32f966ba0c28a4b0 Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Sat, 11 Jul 2026 12:10:36 +0800 Subject: [PATCH 08/12] fix(web-shell): remove duplicate composer tag icon option --- packages/web-shell/client/customization.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/web-shell/client/customization.tsx b/packages/web-shell/client/customization.tsx index 31c484b78b9..fc282d8f84c 100644 --- a/packages/web-shell/client/customization.tsx +++ b/packages/web-shell/client/customization.tsx @@ -389,7 +389,6 @@ export type LoadingPhrasesResolver = ( ) => readonly string[] | undefined | null; export interface WebShellCustomization { - composerTagIcons?: WebShellComposerTagIconMap; renderToolHeaderExtra?: ToolHeaderExtraRenderer; renderWelcomeHeader?: WelcomeHeaderRenderer; renderWelcomeFooter?: WelcomeFooterRenderer; From 32708b41ee34d88b7b1f494017e218c7baa1ca7d Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Sat, 11 Jul 2026 13:53:20 +0800 Subject: [PATCH 09/12] fix(web-shell): forward plan prompt annotations --- packages/web-shell/client/App.test.tsx | 45 ++++++++++++++++++- packages/web-shell/client/App.tsx | 1 + .../client/hooks/useComposerCore.dom.test.tsx | 15 +++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index 2b36f5201e7..569e802ff72 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, createRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon'; import type { WebShellApi } from './App'; type StreamingState = 'idle' | 'responding'; @@ -29,6 +30,7 @@ type ChatEditorTestProps = { text: string, images?: undefined, commitAccepted?: () => void, + metadata?: { inputAnnotations?: DaemonInputAnnotation[] }, ) => boolean | void; skills?: Array<{ name: string; description: string }>; isPreparing?: boolean; @@ -105,6 +107,7 @@ const { }, testState: { prompt: 'hello', + inputAnnotations: undefined as DaemonInputAnnotation[] | undefined, streamingState: 'idle' as StreamingState, blocks: [] as unknown[], latestChatEditorProps: null as ChatEditorTestProps | null, @@ -204,8 +207,15 @@ vi.mock('./components/ChatEditor', async () => { { 'data-testid': 'submit', 'data-preparing': props.isPreparing ? 'true' : 'false', - onClick: () => - props.onSubmit(testState.prompt, undefined, editorCommit), + onClick: () => { + if (testState.inputAnnotations) { + props.onSubmit(testState.prompt, undefined, editorCommit, { + inputAnnotations: testState.inputAnnotations, + }); + return; + } + props.onSubmit(testState.prompt, undefined, editorCommit); + }, type: 'button', }, 'submit', @@ -536,6 +546,7 @@ beforeEach(() => { mockConnection.loadingTranscript = false; mockConnection.catchingUp = false; testState.prompt = 'hello'; + testState.inputAnnotations = undefined; testState.streamingState = 'idle'; testState.blocks = []; testState.latestChatEditorProps = null; @@ -919,6 +930,7 @@ describe('App session callbacks', () => { 'queued', undefined, undefined, + undefined, ); expect(onSessionChange).toHaveBeenCalledWith({ type: 'submit', @@ -965,6 +977,35 @@ describe('App session callbacks', () => { expect(editorClear).not.toHaveBeenCalled(); }); + it('forwards input annotations for /plan prompts in active sessions', async () => { + const annotation: DaemonInputAnnotation = { + type: 'reference', + text: '@.husky/', + start: 0, + end: 8, + reference: { + id: '.husky/', + value: '.husky/', + serialized: '@.husky/', + }, + }; + const { container } = renderApp(); + await flush(); + + testState.prompt = '/plan @.husky/ explain'; + testState.inputAnnotations = [annotation]; + await clickSubmit(container); + await flush(); + + expect(mockSessionActions.setApprovalMode).toHaveBeenCalledWith('plan'); + expect(mockSessionActions.sendPrompt).toHaveBeenCalledWith( + '@.husky/ explain', + expect.objectContaining({ + inputAnnotations: [annotation], + }), + ); + }); + it('dispatches turn_complete only for the session that was streaming', async () => { const onSessionChange = vi.fn(); const { container, rerender } = renderApp({ onSessionChange }); diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index 3902cd20430..4bdac7cb022 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -3428,6 +3428,7 @@ export function App({ if (prompt) { return sendPrompt(prompt, images, { clearComposerOnPromptStart: true, + inputAnnotations: metadata?.inputAnnotations, }).catch((error: unknown) => reportError(error, 'Failed to send plan prompt'), ); diff --git a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx index 2a7b77c78fc..bb787a79e08 100644 --- a/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx +++ b/packages/web-shell/client/hooks/useComposerCore.dom.test.tsx @@ -160,6 +160,21 @@ describe('useComposerCore inline tags', () => { ' explain', undefined, expect.any(Function), + { + inputAnnotations: [ + { + end: 9, + reference: { + id: 'orders', + serialized: '
', + value: 'orders', + }, + start: 0, + text: '
', + type: 'reference', + }, + ], + }, ); }); }); From b4295bb853ef0983525de47bca4c449c3f4f2e0e Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Sat, 11 Jul 2026 18:39:28 +0800 Subject: [PATCH 10/12] test(web-shell): cover composer annotation edge cases --- .../client/hooks/useQueuedPrompts.ts | 11 +- .../client/utils/composerTag.test.ts | 125 ++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/web-shell/client/hooks/useQueuedPrompts.ts b/packages/web-shell/client/hooks/useQueuedPrompts.ts index 57ec6efbf02..df98c6a623b 100644 --- a/packages/web-shell/client/hooks/useQueuedPrompts.ts +++ b/packages/web-shell/client/hooks/useQueuedPrompts.ts @@ -438,12 +438,15 @@ export function useQueuedPrompts({ const submitAbort = new AbortController(); submitAbortControllersRef.current.add(submitAbort); const queuedImages = images ? [...images] : undefined; + const queuedAnnotations = inputAnnotations + ? [...inputAnnotations] + : undefined; const nextPrompt: QueuedPrompt = { id: localId, sessionId: targetSessionId, text: trimmed, images: queuedImages, - inputAnnotations, + inputAnnotations: queuedAnnotations, onComplete, serverState: 'submitting', }; @@ -453,7 +456,7 @@ export function useQueuedPrompts({ sessionActions .submitPrompt(trimmed, { images, - inputAnnotations, + inputAnnotations: queuedAnnotations, optimisticUserMessage: false, sessionId: targetSessionId, signal: submitAbort.signal, @@ -479,7 +482,9 @@ export function useQueuedPrompts({ store.appendLocalUserMessage( trimmed, toStoreImages(queuedImages), - inputAnnotations?.length ? { inputAnnotations } : undefined, + queuedAnnotations?.length + ? { inputAnnotations: queuedAnnotations } + : undefined, ); } const next = queuedPromptsRef.current.filter( diff --git a/packages/web-shell/client/utils/composerTag.test.ts b/packages/web-shell/client/utils/composerTag.test.ts index 40937c27f19..131c4dd9024 100644 --- a/packages/web-shell/client/utils/composerTag.test.ts +++ b/packages/web-shell/client/utils/composerTag.test.ts @@ -216,6 +216,88 @@ describe('composer tag input annotations', () => { ]); }); + it('returns no annotations for empty tags', () => { + expect( + createInputAnnotationsFromComposerTags('show @Makefile', []), + ).toEqual([]); + }); + + it('skips tags whose serialized text is absent from the prompt', () => { + expect( + createInputAnnotationsFromComposerTags('show @Makefile', [ + { + id: 'file:@LICENSE', + kind: 'file', + value: 'LICENSE', + serialized: '@LICENSE', + }, + { + id: 'file:@Makefile', + kind: 'file', + value: 'Makefile', + serialized: '@Makefile', + }, + ]), + ).toEqual([ + { + type: 'reference', + start: 5, + end: 14, + text: '@Makefile', + reference: { + id: 'file:@Makefile', + kind: 'file', + value: 'Makefile', + serialized: '@Makefile', + }, + }, + ]); + }); + + it('matches repeated serialized references in order', () => { + expect( + createInputAnnotationsFromComposerTags('@file @file', [ + { + id: 'file:first', + kind: 'file', + value: 'first', + serialized: '@file', + }, + { + id: 'file:second', + kind: 'file', + value: 'second', + serialized: '@file', + }, + ]), + ).toEqual([ + { + type: 'reference', + start: 0, + end: 5, + text: '@file', + reference: { + id: 'file:first', + kind: 'file', + value: 'first', + serialized: '@file', + }, + }, + { + type: 'reference', + start: 6, + end: 11, + text: '@file', + reference: { + id: 'file:second', + kind: 'file', + value: 'second', + serialized: '@file', + }, + }, + ]); + }); + it('uses annotations for custom provider references', () => { expect( splitComposerTagContentByAnnotations('open @dataset:users now', [ @@ -370,4 +452,47 @@ describe('composer tag input annotations', () => { ]), ).toEqual([{ type: 'text', text: 'list @.qwen/ files' }]); }); + + it('skips overlapping annotations', () => { + expect( + splitComposerTagContentByAnnotations('open @one @two', [ + { + type: 'reference', + start: 5, + end: 9, + text: '@one', + reference: { + id: 'file:@one', + kind: 'file', + value: 'one', + serialized: '@one', + }, + }, + { + type: 'reference', + start: 8, + end: 13, + text: 'e @tw', + reference: { + id: 'file:overlap', + kind: 'file', + value: 'overlap', + serialized: 'e @tw', + }, + }, + ]), + ).toEqual([ + { type: 'text', text: 'open ' }, + { + type: 'reference', + tag: { + id: 'file:@one', + kind: 'file', + value: 'one', + serialized: '@one', + }, + }, + { type: 'text', text: ' @two' }, + ]); + }); }); From 6c4e142d402cbc0eece7e195b4a99c9c3e1c10a6 Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Sat, 11 Jul 2026 20:08:21 +0800 Subject: [PATCH 11/12] fix(web-shell): forward split pane prompt annotations --- .../client/components/ChatPane.test.tsx | 55 ++++++++++++++++++- .../web-shell/client/components/ChatPane.tsx | 8 ++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/web-shell/client/components/ChatPane.test.tsx b/packages/web-shell/client/components/ChatPane.test.tsx index ddfb920e79d..4e5e9ef0e1d 100644 --- a/packages/web-shell/client/components/ChatPane.test.tsx +++ b/packages/web-shell/client/components/ChatPane.test.tsx @@ -17,7 +17,12 @@ let connectionState: any; let streamingStateValue: string; let pendingPermission: any; let latestOnSubmit: - | ((text: string, images?: unknown, commit?: () => void) => boolean) + | (( + text: string, + images?: unknown, + commit?: () => void, + metadata?: unknown, + ) => boolean) | undefined; let latestChatEditorProps: any; let latestFollowupAccept: ((suggestion: string) => void) | undefined; @@ -317,6 +322,28 @@ describe('ChatPane', () => { }); }); + it('forwards composer annotations with an idle prompt', () => { + const inputAnnotations = [ + { + start: 6, + end: 14, + text: '@.husky/', + type: 'file', + data: { path: '.husky/' }, + }, + ]; + render(); + act(() => { + latestOnSubmit!('check @.husky/', undefined, undefined, { + inputAnnotations, + }); + }); + expect(sendPrompt).toHaveBeenCalledWith('check @.husky/', { + inputAnnotations, + onAdmitted: expect.any(Function), + }); + }); + it('queues a prompt while the pane is already running', () => { streamingStateValue = 'responding'; render(); @@ -330,6 +357,32 @@ describe('ChatPane', () => { expect(sendPrompt).not.toHaveBeenCalled(); }); + it('forwards composer annotations with a queued prompt', () => { + streamingStateValue = 'responding'; + const inputAnnotations = [ + { + start: 6, + end: 14, + text: '@.husky/', + type: 'file', + data: { path: '.husky/' }, + }, + ]; + render(); + act(() => { + latestOnSubmit!('queue @.husky/', undefined, undefined, { + inputAnnotations, + }); + }); + expect(enqueuePrompt).toHaveBeenCalledWith( + 'queue @.husky/', + undefined, + undefined, + inputAnnotations, + ); + expect(sendPrompt).not.toHaveBeenCalled(); + }); + it('forwards images with a queued prompt', () => { streamingStateValue = 'responding'; const images = [{ data: 'image-data', media_type: 'image/png' }]; diff --git a/packages/web-shell/client/components/ChatPane.tsx b/packages/web-shell/client/components/ChatPane.tsx index d1a40d562e7..5d677f34ae4 100644 --- a/packages/web-shell/client/components/ChatPane.tsx +++ b/packages/web-shell/client/components/ChatPane.tsx @@ -19,6 +19,7 @@ import { extractPendingPermission } from '../adapters/transcriptAdapter'; import type { PromptImage } from '../adapters/promptTypes'; import type { ComposerSubmitCommit, + ComposerSubmitMetadata, EditorHandle, } from '../hooks/useComposerCore'; import { useQueuedPrompts } from '../hooks/useQueuedPrompts'; @@ -154,14 +155,17 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) { text: string, images?: PromptImage[], commitAccepted?: ComposerSubmitCommit, + metadata?: ComposerSubmitMetadata, ): boolean => { const trimmed = text.trim(); if (!trimmed) return false; if (connection.status !== 'connected') return false; + const inputAnnotations = metadata?.inputAnnotations; if (streamingStateRef.current === 'idle') { actions .sendPrompt(trimmed, { ...(images && images.length ? { images } : {}), + ...(inputAnnotations ? { inputAnnotations } : {}), onAdmitted: () => { clearFollowup(); commitAccepted?.(); @@ -172,7 +176,9 @@ export function ChatPane({ title, onClose, onError }: ChatPaneProps) { ); return false; } - return enqueuePrompt(trimmed, images); + return inputAnnotations + ? enqueuePrompt(trimmed, images, undefined, inputAnnotations) + : enqueuePrompt(trimmed, images); }, [actions, clearFollowup, connection.status, enqueuePrompt, reportError], ); From 29fa00100a397e9368db0eb013765b20e5afda77 Mon Sep 17 00:00:00 2001 From: "zhanghuapeng.zhp" Date: Sun, 12 Jul 2026 00:09:42 +0800 Subject: [PATCH 12/12] fix(web-shell): guard malformed input annotations --- packages/web-shell/client/utils/composerTag.test.ts | 13 +++++++++++++ packages/web-shell/client/utils/composerTag.ts | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/web-shell/client/utils/composerTag.test.ts b/packages/web-shell/client/utils/composerTag.test.ts index 131c4dd9024..396ef3f005e 100644 --- a/packages/web-shell/client/utils/composerTag.test.ts +++ b/packages/web-shell/client/utils/composerTag.test.ts @@ -453,6 +453,19 @@ describe('composer tag input annotations', () => { ).toEqual([{ type: 'text', text: 'list @.qwen/ files' }]); }); + it('leaves malformed reference annotations as text', () => { + expect( + splitComposerTagContentByAnnotations('list @.qwen/ files', [ + { + type: 'reference', + start: 5, + end: 12, + text: '@.qwen/', + } as unknown as DaemonInputAnnotation, + ]), + ).toEqual([{ type: 'text', text: 'list @.qwen/ files' }]); + }); + it('skips overlapping annotations', () => { expect( splitComposerTagContentByAnnotations('open @one @two', [ diff --git a/packages/web-shell/client/utils/composerTag.ts b/packages/web-shell/client/utils/composerTag.ts index c6ac1d3a444..41a037c5554 100644 --- a/packages/web-shell/client/utils/composerTag.ts +++ b/packages/web-shell/client/utils/composerTag.ts @@ -102,8 +102,12 @@ export function splitComposerTagContentByAnnotations( let cursor = 0; for (const annotation of inputAnnotations) { if (annotation.type !== 'reference') continue; - const { start, end, reference, text } = annotation; + const { start, end, text } = annotation; + const reference: DaemonInputAnnotation['reference'] | undefined = + annotation.reference; if ( + !reference || + typeof reference.id !== 'string' || start < cursor || end <= start || end > content.length ||