Skip to content

Commit 034988a

Browse files
authored
Merge pull request #3188 from kev1n77/fmy/bugfix
fix(web-ui): correct composer inline reference pills and the '@' picker
2 parents cc6d497 + cfad872 commit 034988a

11 files changed

Lines changed: 569 additions & 39 deletions

‎src/web-ui/src/flow_chat/components/ChatInput.tsx‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ import {
217217
replaceLeadingSlashCommandWithSkillToken,
218218
} from '../utils/skillPromptReference';
219219
import { resolveChatInputQuickSkillShortcuts } from '../utils/chatInputQuickSkills';
220+
import { contextPickerOwnsKey } from '../utils/chatInputKeyOwnership';
220221
import { useDeepReviewConsent } from './DeepReviewConsentDialog';
221222
import { useSessionReviewActivity } from '../hooks/useSessionReviewActivity';
222223
import { shouldBlockReviewCommand } from '../utils/deepReviewCommandGuard';
@@ -5619,6 +5620,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
56195620
return;
56205621
}
56215622

5623+
// The '@' reference picker owns its navigation and acceptance keys through
5624+
// its overlay layer, which the coordinator routes after React handlers.
5625+
if (contextPickerOwnsKey({ contextPickerActive: contextTriggerState.isActive, key: e.key })) {
5626+
return;
5627+
}
5628+
56225629
if (slashCommandState.isActive) {
56235630
const items = getActiveSlashPickerItems();
56245631
const maxIndex = Math.max(0, items.length - 1);
@@ -5813,7 +5820,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
58135820
handleSendOrCancel();
58145821
}
58155822

5816-
}, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, hasSendableInput, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]);
5823+
}, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, contextTriggerState.isActive, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, hasSendableInput, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]);
58175824

58185825
const handleImeCompositionStart = useCallback(() => {
58195826
isImeComposingRef.current = true;

‎src/web-ui/src/flow_chat/components/RichTextInput.scss‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@
153153
overflow: hidden;
154154
text-overflow: ellipsis;
155155
white-space: nowrap;
156+
// Centering the label line box leaves the lowercase text body about 1px below
157+
// the optically centered badge icon and dismiss glyph. Lift the label so all
158+
// three read as one row.
159+
transform: translateY(-1px);
156160
}
157161

158162
.rich-text-tag-pill__badge {

‎src/web-ui/src/flow_chat/components/RichTextInput.test.tsx‎

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,38 @@ describeWithJsdom('RichTextInput external sync', () => {
138138

139139
function paste(
140140
editor: HTMLDivElement,
141-
options: { items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>; types?: string[]; text?: string },
141+
options: {
142+
items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>;
143+
types?: string[];
144+
text?: string;
145+
html?: string;
146+
},
142147
) {
143148
const event = new window.Event('paste', { bubbles: true, cancelable: true });
144149
Object.defineProperty(event, 'clipboardData', {
145150
value: {
146151
items: options.items ?? [],
147152
types: options.types ?? [],
148-
getData: (type: string) => type === 'text/plain' ? options.text ?? '' : '',
153+
getData: (type: string) => {
154+
if (type === 'text/plain') return options.text ?? '';
155+
if (type === 'text/html') return options.html ?? '';
156+
return '';
157+
},
149158
},
150159
});
151160
editor.dispatchEvent(event);
152161
}
153162

163+
function copy(editor: HTMLDivElement) {
164+
const clipboard = new Map<string, string>();
165+
const event = new window.Event('copy', { bubbles: true, cancelable: true });
166+
Object.defineProperty(event, 'clipboardData', {
167+
value: { setData: (type: string, value: string) => clipboard.set(type, value) },
168+
});
169+
editor.dispatchEvent(event);
170+
return clipboard;
171+
}
172+
154173
function setCaret(editor: HTMLDivElement, offset: number) {
155174
const selection = window.getSelection();
156175
const range = document.createRange();
@@ -263,6 +282,89 @@ describeWithJsdom('RichTextInput external sync', () => {
263282
expect(editor.querySelector('[data-large-paste-placeholder]')).toBeTruthy();
264283
});
265284

285+
it('rebuilds capsules from pasted inline token text', async () => {
286+
const onChange = vi.fn();
287+
await act(async () => {
288+
root.render(
289+
<RichTextInput
290+
value=""
291+
onChange={onChange}
292+
contexts={emptyContexts}
293+
onRemoveContext={() => {}}
294+
/>,
295+
);
296+
});
297+
const editor = container.querySelector('.rich-text-input') as HTMLDivElement;
298+
setCaret(editor, 0);
299+
300+
paste(editor, { types: ['text/plain'], text: 'run [$pdf] and [$doc] please' });
301+
302+
const pills = Array.from(
303+
editor.querySelectorAll<HTMLElement>('[data-inline-token-type="skill-ref"]'),
304+
);
305+
expect(pills.map(pill => pill.dataset.tagFormat)).toEqual(['[$pdf]', '[$doc]']);
306+
expect(editor.textContent).toBe('run pdf× and doc× please');
307+
expect(onChange).toHaveBeenLastCalledWith('run [$pdf] and [$doc] please', emptyContexts);
308+
});
309+
310+
it('restores capsules from the composer clipboard payload of a copied message', async () => {
311+
const onChange = vi.fn();
312+
await act(async () => {
313+
root.render(
314+
<RichTextInput
315+
value=""
316+
onChange={onChange}
317+
contexts={emptyContexts}
318+
onRemoveContext={() => {}}
319+
/>,
320+
);
321+
});
322+
const editor = container.querySelector('.rich-text-input') as HTMLDivElement;
323+
setCaret(editor, 0);
324+
325+
paste(editor, {
326+
types: ['text/plain', 'text/html'],
327+
text: '[Skill: pdf] summarize it',
328+
html: '<div data-openbitfun-composer-clipboard="1" '
329+
+ 'data-openbitfun-composer-clipboard-tokens="[$pdf] summarize it">'
330+
+ '[Skill: pdf] summarize it</div>',
331+
});
332+
333+
expect(editor.querySelector<HTMLElement>('[data-inline-token-type="skill-ref"]')?.dataset.tagFormat)
334+
.toBe('[$pdf]');
335+
expect(onChange).toHaveBeenLastCalledWith('[$pdf] summarize it', emptyContexts);
336+
});
337+
338+
it('copies a selection as composer token text with a marked payload', async () => {
339+
const inputRef = createRef<RichTextInputElement>();
340+
await act(async () => {
341+
root.render(
342+
<RichTextInput
343+
ref={inputRef}
344+
value="compare [$pdf] with [$doc]"
345+
onChange={() => {}}
346+
contexts={emptyContexts}
347+
onRemoveContext={() => {}}
348+
/>,
349+
);
350+
});
351+
const editor = inputRef.current!;
352+
353+
const selection = window.getSelection()!;
354+
const range = document.createRange();
355+
range.selectNodeContents(editor);
356+
selection.removeAllRanges();
357+
selection.addRange(range);
358+
359+
const clipboard = copy(editor);
360+
361+
expect(clipboard.get('text/plain')).toBe('compare [$pdf] with [$doc]');
362+
const html = clipboard.get('text/html') ?? '';
363+
expect(html).toContain('data-openbitfun-composer-clipboard="1"');
364+
expect(html).toContain('data-openbitfun-composer-clipboard-tokens="compare [$pdf] with [$doc]"');
365+
expect(html).not.toContain('rich-text-tag-pill__remove');
366+
});
367+
266368
it('keeps the existing DOM node when parent echoes local input', async () => {
267369
const harnessRef = createRef<HarnessHandle>();
268370
const editor = await renderHarness(harnessRef);

0 commit comments

Comments
 (0)