fix(web-shell): keep inline chip annotations on the chip's real range (#12992)

* fix(web-shell): keep inline chip annotations on the chip's real range

createInputAnnotationsFromComposerTags re-located every tag in the final
prompt with indexOf, so plain text spelled like an inline chip's
serialized text stole the annotation and the bubble rendered the typed
look-alike as the chip (#12980). The composer already knows each inline
chip's editor range; thread those placements through submit (shifted
past the prompt prefix) and emit their annotations directly, keeping the
forward indexOf scan for tags without a known placement.

Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Patrol-Run: qwen-issue-patrol/jmum9v0vg30

* test(web-shell): pin chip annotation offsets for a nonzero prompt prefix

Every case added for #12980 ran with promptPrefixLength === 0, so the one
line of new arithmetic (promptText.length - text.length, and the shifts
that use it) was unpinned. Adds a dom case with a top tag attached
(prefix 6, chip must land on [24, 28) instead of the plain-text look-alike
at [14, 18)) and one in shell mode (prefix 1, chip must land on [19, 23)).
Forcing the prefix to 0 makes each lose its chip annotation entirely.

Also applies Prettier to this PR's two test files, which was the only
thing `Lint & Static` objected to.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmumcpwj7eg

* fix(web-shell): fall back to a unique textual match for stale chip placements

Known placements are pre-substitution editor offsets, so once the document
text under a chip drifts from its serialized form every later chip's range
goes stale too and the slice guard dropped all of their annotations. On a
mismatch, fall back to the text when the serialized form occurs exactly
once — a unique match cannot annotate the wrong span — and keep dropping to
plain text when it is repeated or absent. Addresses review thread R1-1.

* test(web-shell): pin the stale-placement fallback search origin

A stale known placement recovers through content.indexOf(serialized), which searches from the prompt start. Nothing pinned that origin: every existing placement either starts at 0 or names a serialized form absent from the content, so anchoring the search at the recorded start was behaviourally indistinguishable. Add a placement whose recorded start sits past its true occurrence; it fails under an indexOf(serialized, start) mutant and passes without it.

Co-authored-by: Qwen-Coder <[email protected]>

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmumw0cjpfe

* test(web-shell): pin reference.id in multi-annotation composerTag assertions

R3-1: the two multi-annotation assertions in composerTag.test.ts projected
each annotation down to [start, end], so nothing in the repo pinned that a
recovered span is emitted with its own placement's tag. Offsets come from the
span's serialized form while identity comes from placement.tag, and only the
placement loop joins them, so a mis-zip that preserves annotation order was
invisible. A wrong pairing renders a chip whose reference.id points at a
different file, and that id is embedded in the submit-path dedupe key, so it
propagates into replay reconciliation and is persisted with the transcript.

Both assertions now project [start, end, reference.id]:
- 'combines searched tags with known inline placements'
- 'recovers a stale placement whose true occurrence is behind its recorded
  start' (the only test where two placements both take the stale fallback)

The expectations assert the ids the fixtures actually declare (file:@ctx /
file:@foo and file:@alpha / file:@beta) and deliberately do not gain a
serialized key: these fixtures set only id/kind/value, and
createReferenceAnnotation spreads serialized conditionally.

Discriminating power verified with a temporary mis-zip mutant in the stale
fallback branch (collect spans and tags separately, emit spans[i] with
tags[n-1-i].tag, annotation order preserved): the strengthened assertion reds
with "expected [ [ +0, 6, 'file:@beta' ], ...(1) ] to deeply equal
[ [ +0, 6, 'file:@alpha' ], ...(1) ]" and it is the only one of the 36 tests
in the file that does. The mutant was reverted; production code is unchanged.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmun8vb7vg3

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <qwen-coder @alibabacloud.com>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-01 04:41:22 +00:00
1 parent a4bf0026c0
commit 339bdeb013
4 files changed
+439 -16

No files matched your search

@@ -0,0 +1,223 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// @vitest-environment jsdom
//
// Reproduction for https://github.com/QwenLM/qwen-code/issues/12980
// "reference chip attaches to earlier matching plain text after submit".
//
// The composer knows each inline chip's real CodeMirror range, but the
// submit path dropped it and createInputAnnotationsFromComposerTags
// re-located every tag with content.indexOf(serialized, cursor), so plain
// text spelled like a chip's serialized text won the annotation. With
// `literal @foo then @foo` the inline chip sits at [18, 22) yet the
// generated annotation covered [8, 12), so the user-message bubble rendered
// the typed text as the chip and the selected reference as plain text.
import { afterEach, describe, expect, it } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { DaemonInputAnnotation } from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../i18n';
import { splitComposerTagContentByAnnotations } from '../utils/composerTag';
import { useComposerCore, type UseComposerCoreReturn } from './useComposerCore';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let container: HTMLDivElement | null = null;
let root: Root | null = null;
let latest: UseComposerCoreReturn | null = null;
let submittedText: string | null = null;
let submittedAnnotations: readonly DaemonInputAnnotation[] = [];
function Harness() {
const composer = useComposerCore({
onSubmit: (text, _images, _files, _commitAccepted, metadata) => {
submittedText = text;
submittedAnnotations = metadata?.inputAnnotations ?? [];
return true;
},
commands: [],
editorTheme: {},
});
latest = composer;
return <div ref={composer.containerRef} />;
}
async function mount() {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root!.render(
<I18nProvider language="en">
<Harness />
</I18nProvider>,
);
});
}
function appendPlainText(text: string) {
const view = latest!.viewRef.current!;
act(() => {
view.dispatch({
changes: { from: view.state.doc.length, insert: text },
});
});
}
function addFileChipAtEnd(serialized: string) {
act(() => {
latest!.handle.addTags(
[{ id: `file:${serialized}`, kind: 'file', value: serialized }],
{ placement: 'inline', position: 'end' },
);
});
}
function addTopFileChip(serialized: string) {
act(() => {
latest!.handle.addTags(
[{ id: `file:${serialized}`, kind: 'file', value: serialized }],
{ placement: 'top' },
);
});
}
function setShellMode(enabled: boolean) {
act(() => {
latest!.setShellMode(enabled);
});
}
async function submit() {
await act(async () => {
latest!.submitText();
});
}
function annotationRanges() {
return submittedAnnotations.map(({ start, end }) => [start, end]);
}
afterEach(async () => {
if (root) {
await act(async () => {
root!.unmount();
});
}
root = null;
container?.remove();
container = null;
latest = null;
submittedText = null;
submittedAnnotations = [];
document.body.innerHTML = '';
});
describe('useComposerCore issue #12980 inline chip annotation offsets', () => {
it('attaches the annotation to the inline chip, not earlier matching plain text', async () => {
await mount();
appendPlainText('literal @foo then ');
addFileChipAtEnd('@foo');
expect(latest!.viewRef.current!.state.doc.toString()).toBe(
'literal @foo then @foo ',
);
await submit();
expect(submittedText).toBe('literal @foo then @foo');
expect(annotationRanges()).toEqual([[18, 22]]);
// The bubble must keep the typed look-alike plain and render the
// selected reference as the chip.
expect(
splitComposerTagContentByAnnotations(
submittedText!,
submittedAnnotations,
),
).toEqual([
{ type: 'text', text: 'literal @foo then ' },
{
type: 'reference',
tag: expect.objectContaining({ id: 'file:@foo' }),
},
]);
});
it('keeps annotating a chip that has no plain-text look-alike (control)', async () => {
await mount();
appendPlainText('see ');
addFileChipAtEnd('@foo');
await submit();
expect(submittedText).toBe('see @foo');
expect(annotationRanges()).toEqual([[4, 8]]);
});
it('keeps annotating the chip when it precedes matching plain text (control)', async () => {
await mount();
addFileChipAtEnd('@foo');
appendPlainText('then @foo');
await submit();
expect(submittedText).toBe('@foo then @foo');
expect(annotationRanges()).toEqual([[0, 4]]);
});
it('shifts the chip past a top tag prefix', async () => {
await mount();
addTopFileChip('@top');
appendPlainText('literal @foo then ');
addFileChipAtEnd('@foo');
expect(latest!.viewRef.current!.state.doc.toString()).toBe(
'literal @foo then @foo ',
);
await submit();
// The top tag is serialized ahead of the editor text, so the prompt is
// '@top' + blank separator + text and promptPrefixLength is 6. The chip's
// editor range [18, 22) must land on [24, 28), not on the plain-text
// look-alike at [14, 18).
expect(submittedText).toBe('@top\n\nliteral @foo then @foo');
expect(annotationRanges()).toEqual([
[0, 4],
[24, 28],
]);
expect(
splitComposerTagContentByAnnotations(
submittedText!,
submittedAnnotations,
),
).toEqual([
{
type: 'reference',
tag: expect.objectContaining({ id: 'file:@top' }),
},
{ type: 'text', text: '\n\nliteral @foo then ' },
{
type: 'reference',
tag: expect.objectContaining({ id: 'file:@foo' }),
},
]);
});
it('shifts the chip past the shell-mode "!" prefix', async () => {
await mount();
setShellMode(true);
appendPlainText('literal @foo then ');
addFileChipAtEnd('@foo');
await submit();
// Shell mode prepends '!', so promptPrefixLength is 1 and the chip's
// editor range [18, 22) must land on [19, 23).
expect(submittedText).toBe('!literal @foo then @foo');
expect(annotationRanges()).toEqual([[19, 23]]);
});
});
@@ -2861,9 +2861,22 @@ export function useComposerCore(
const prompt = buildComposerPrompt(text, tags);
const isShellMode = shellModeRef.current;
const promptText = isShellMode && prompt ? `!${prompt}` : prompt;
// Inline chip placements are offsets into the pre-substitution editor
// document; they stay valid for `text` only while substitution preserves
// their length, and the annotation generator validates each range (with
// a unique-match fallback) for anything else. Shift them past the prompt
// prefix (top tags plus the blank separator, and the shell-mode `!`) so
// annotations land on the chips instead of earlier plain text spelled
// the same (#12980).
const promptPrefixLength = promptText.length - text.length;
const generatedInputAnnotations = createInputAnnotationsFromComposerTags(
promptText,
[...tags, ...normalizedInlineTags.map((placement) => placement.tag)],
tags,
normalizedInlineTags.map((placement) => ({
start: placement.start + promptPrefixLength,
end: placement.end + promptPrefixLength,
tag: placement.tag,
})),
);
const inputAnnotations = [...generatedInputAnnotations];
const annotationKeys = new Set(
@@ -420,6 +420,139 @@ describe('composer tag input annotations', () => {
]);
});
it('uses known placements instead of matching earlier identical text', () => {
// Issue #12980: plain text spelled like a chip's serialized text must
// not steal the chip's annotation.
const content = 'literal @foo then @foo';
expect(
createInputAnnotationsFromComposerTags(
content,
[],
[
{
start: 18,
end: 22,
tag: { id: 'file:@foo', kind: 'file', value: '@foo' },
},
],
),
).toEqual([
{
type: 'reference',
start: 18,
end: 22,
text: '@foo',
reference: { id: 'file:@foo', kind: 'file', value: '@foo' },
},
]);
});
it('combines searched tags with known inline placements', () => {
const content = '@ctx\n\nliteral @foo then @foo';
expect(
createInputAnnotationsFromComposerTags(
content,
[{ id: 'file:@ctx', kind: 'file', value: '@ctx' }],
[
{
start: 24,
end: 28,
tag: { id: 'file:@foo', kind: 'file', value: '@foo' },
},
],
).map(({ start, end, reference }) => [start, end, reference.id]),
).toEqual([
[0, 4, 'file:@ctx'],
[24, 28, 'file:@foo'],
]);
});
it('skips known placements whose range does not match the prompt text', () => {
const content = 'literal @foo then @foo';
expect(
createInputAnnotationsFromComposerTags(
content,
[],
[
{
start: 0,
end: 4,
tag: { id: 'file:@foo', kind: 'file', value: '@foo' },
},
{
start: 8,
end: 30,
tag: { id: 'file:@bar', kind: 'file', value: '@bar' },
},
],
),
).toEqual([]);
});
it('falls back to a unique textual match when a known placement is stale', () => {
// The editor range no longer matches the prompt text (the document text
// under a chip drifted from its serialized form, e.g. an edit inside the
// chip's range): a serialized form occurring exactly once can still be
// annotated without risking the wrong span, while a repeated one stays
// plain text.
const content = 'open @foo and @bar then @foo';
expect(
createInputAnnotationsFromComposerTags(
content,
[],
[
{
start: 0,
end: 3,
tag: { id: 'file:@bar', kind: 'file', value: '@bar' },
},
{
start: 0,
end: 3,
tag: { id: 'file:@foo', kind: 'file', value: '@foo' },
},
],
),
).toEqual([
{
type: 'reference',
start: 14,
end: 18,
text: '@bar',
reference: { id: 'file:@bar', kind: 'file', value: '@bar' },
},
]);
});
it('recovers a stale placement whose true occurrence is behind its recorded start', () => {
// The first chip's inserted text was longer than its serialized form, so
// every later chip's recorded range sits past its true position. The
// fallback therefore has to search the whole prompt: anchoring the search
// at the stale start would silently drop the later chip's annotation.
const content = '@alpha X @beta';
expect(
createInputAnnotationsFromComposerTags(
content,
[],
[
{
start: 0,
end: 12,
tag: { id: 'file:@alpha', kind: 'file', value: '@alpha' },
},
{
start: 15,
end: 21,
tag: { id: 'file:@beta', kind: 'file', value: '@beta' },
},
],
).map(({ start, end, reference }) => [start, end, reference.id]),
).toEqual([
[0, 6, 'file:@alpha'],
[9, 14, 'file:@beta'],
]);
});
it('uses annotations for custom provider references', () => {
expect(
splitComposerTagContentByAnnotations('open @dataset:users now', [
+69 -15
View File
@@ -162,9 +162,43 @@ export function isBuiltinComposerTagIconUrl(
return iconUrl !== undefined && builtinTagIconUrlSet.has(iconUrl);
}
// An inline chip's editor-known range, expressed in final prompt offsets.
// The composer already knows where each inline chip lives; carrying the
// range through submit keeps the annotation on the chip instead of an
// earlier plain-text look-alike (#12980).
export interface ComposerTagKnownPlacement {
start: number;
end: number;
tag: WebShellComposerTag;
}
function createReferenceAnnotation(
tag: WebShellComposerTag,
start: number,
end: number,
serialized: string,
): DaemonInputAnnotation {
return {
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.metadata !== undefined ? { metadata: tag.metadata } : {}),
...(tag.serialized ? { serialized: tag.serialized } : {}),
...(tag.removable !== undefined ? { removable: tag.removable } : {}),
},
};
}
export function createInputAnnotationsFromComposerTags(
content: string,
tags: readonly WebShellComposerTag[],
knownPlacements?: readonly ComposerTagKnownPlacement[],
): DaemonInputAnnotation[] {
const annotations: DaemonInputAnnotation[] = [];
let cursor = 0;
@@ -174,23 +208,43 @@ export function createInputAnnotationsFromComposerTags(
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.metadata !== undefined ? { metadata: tag.metadata } : {}),
...(tag.serialized ? { serialized: tag.serialized } : {}),
...(tag.removable !== undefined ? { removable: tag.removable } : {}),
},
});
annotations.push(createReferenceAnnotation(tag, start, end, serialized));
cursor = end;
}
for (const placement of knownPlacements ?? []) {
const serialized = getComposerTagSerialized(placement.tag);
if (!serialized) continue;
const { start, end } = placement;
// Offsets cross a coordinate-space boundary here (editor document vs
// final prompt); never emit an annotation whose text does not match.
if (
start >= 0 &&
end > start &&
end <= content.length &&
content.slice(start, end) === serialized
) {
annotations.push(
createReferenceAnnotation(placement.tag, start, end, serialized),
);
continue;
}
// A stale range (the document text under a chip can drift from its
// serialized form, which also shifts every later chip) must not drop the
// annotation outright: fall back to the text, but only when the
// serialized form occurs exactly once. A unique match cannot annotate
// the wrong span; a repeated one stays plain text rather than guessing
// (#12980).
const first = content.indexOf(serialized);
if (first < 0 || first !== content.lastIndexOf(serialized)) continue;
annotations.push(
createReferenceAnnotation(
placement.tag,
first,
first + serialized.length,
serialized,
),
);
}
return annotations;
}