Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ import {
replaceLeadingSlashCommandWithSkillToken,
} from '../utils/skillPromptReference';
import { resolveChatInputQuickSkillShortcuts } from '../utils/chatInputQuickSkills';
import { contextPickerOwnsKey } from '../utils/chatInputKeyOwnership';
import { useDeepReviewConsent } from './DeepReviewConsentDialog';
import { useSessionReviewActivity } from '../hooks/useSessionReviewActivity';
import { shouldBlockReviewCommand } from '../utils/deepReviewCommandGuard';
Expand Down Expand Up @@ -5619,6 +5620,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

// The '@' reference picker owns its navigation and acceptance keys through
// its overlay layer, which the coordinator routes after React handlers.
if (contextPickerOwnsKey({ contextPickerActive: contextTriggerState.isActive, key: e.key })) {
return;
}

if (slashCommandState.isActive) {
const items = getActiveSlashPickerItems();
const maxIndex = Math.max(0, items.length - 1);
Expand Down Expand Up @@ -5813,7 +5820,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
handleSendOrCancel();
}

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

const handleImeCompositionStart = useCallback(() => {
isImeComposingRef.current = true;
Expand Down
4 changes: 4 additions & 0 deletions src/web-ui/src/flow_chat/components/RichTextInput.scss
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
// Centering the label line box leaves the lowercase text body about 1px below
// the optically centered badge icon and dismiss glyph. Lift the label so all
// three read as one row.
transform: translateY(-1px);
}

.rich-text-tag-pill__badge {
Expand Down
106 changes: 104 additions & 2 deletions src/web-ui/src/flow_chat/components/RichTextInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,19 +138,38 @@ describeWithJsdom('RichTextInput external sync', () => {

function paste(
editor: HTMLDivElement,
options: { items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>; types?: string[]; text?: string },
options: {
items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>;
types?: string[];
text?: string;
html?: string;
},
) {
const event = new window.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
items: options.items ?? [],
types: options.types ?? [],
getData: (type: string) => type === 'text/plain' ? options.text ?? '' : '',
getData: (type: string) => {
if (type === 'text/plain') return options.text ?? '';
if (type === 'text/html') return options.html ?? '';
return '';
},
},
});
editor.dispatchEvent(event);
}

function copy(editor: HTMLDivElement) {
const clipboard = new Map<string, string>();
const event = new window.Event('copy', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { setData: (type: string, value: string) => clipboard.set(type, value) },
});
editor.dispatchEvent(event);
return clipboard;
}

function setCaret(editor: HTMLDivElement, offset: number) {
const selection = window.getSelection();
const range = document.createRange();
Expand Down Expand Up @@ -263,6 +282,89 @@ describeWithJsdom('RichTextInput external sync', () => {
expect(editor.querySelector('[data-large-paste-placeholder]')).toBeTruthy();
});

it('rebuilds capsules from pasted inline token text', async () => {
const onChange = vi.fn();
await act(async () => {
root.render(
<RichTextInput
value=""
onChange={onChange}
contexts={emptyContexts}
onRemoveContext={() => {}}
/>,
);
});
const editor = container.querySelector('.rich-text-input') as HTMLDivElement;
setCaret(editor, 0);

paste(editor, { types: ['text/plain'], text: 'run [$pdf] and [$doc] please' });

const pills = Array.from(
editor.querySelectorAll<HTMLElement>('[data-inline-token-type="skill-ref"]'),
);
expect(pills.map(pill => pill.dataset.tagFormat)).toEqual(['[$pdf]', '[$doc]']);
expect(editor.textContent).toBe('run pdf× and doc× please');
expect(onChange).toHaveBeenLastCalledWith('run [$pdf] and [$doc] please', emptyContexts);
});

it('restores capsules from the composer clipboard payload of a copied message', async () => {
const onChange = vi.fn();
await act(async () => {
root.render(
<RichTextInput
value=""
onChange={onChange}
contexts={emptyContexts}
onRemoveContext={() => {}}
/>,
);
});
const editor = container.querySelector('.rich-text-input') as HTMLDivElement;
setCaret(editor, 0);

paste(editor, {
types: ['text/plain', 'text/html'],
text: '[Skill: pdf] summarize it',
html: '<div data-openbitfun-composer-clipboard="1" '
+ 'data-openbitfun-composer-clipboard-tokens="[$pdf] summarize it">'
+ '[Skill: pdf] summarize it</div>',
});

expect(editor.querySelector<HTMLElement>('[data-inline-token-type="skill-ref"]')?.dataset.tagFormat)
.toBe('[$pdf]');
expect(onChange).toHaveBeenLastCalledWith('[$pdf] summarize it', emptyContexts);
});

it('copies a selection as composer token text with a marked payload', async () => {
const inputRef = createRef<RichTextInputElement>();
await act(async () => {
root.render(
<RichTextInput
ref={inputRef}
value="compare [$pdf] with [$doc]"
onChange={() => {}}
contexts={emptyContexts}
onRemoveContext={() => {}}
/>,
);
});
const editor = inputRef.current!;

const selection = window.getSelection()!;
const range = document.createRange();
range.selectNodeContents(editor);
selection.removeAllRanges();
selection.addRange(range);

const clipboard = copy(editor);

expect(clipboard.get('text/plain')).toBe('compare [$pdf] with [$doc]');
const html = clipboard.get('text/html') ?? '';
expect(html).toContain('data-openbitfun-composer-clipboard="1"');
expect(html).toContain('data-openbitfun-composer-clipboard-tokens="compare [$pdf] with [$doc]"');
expect(html).not.toContain('rich-text-tag-pill__remove');
});

it('keeps the existing DOM node when parent echoes local input', async () => {
const harnessRef = createRef<HarnessHandle>();
const editor = await renderHarness(harnessRef);
Expand Down
Loading
Loading