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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Simulate } from 'react-dom/test-utils';
import { JSDOM } from 'jsdom';
import { UserMessageEditComposer } from './UserMessageEditComposer';
import type { ComposerPresentation } from '../../utils/composerPresentation';
import { createMcpPromptReference } from '../../utils/mcpPromptReference';

vi.mock('../ChatContextPicker', () => ({
ChatContextPicker: () => null,
Expand Down Expand Up @@ -200,6 +201,75 @@ describe('UserMessageEditComposer', () => {
expect(onCancel).not.toHaveBeenCalled();
});

it('focuses the editor when the compact composer surface is clicked away from text', async () => {
await renderComposer();
const textarea = container.querySelector('textarea')!;
const surface = container.querySelector<HTMLElement>('[data-openbitfun-part="surface"]')!;
textarea.blur();
expect(document.activeElement).not.toBe(textarea);

await act(async () => {
surface.dispatchEvent(new dom.window.MouseEvent('mousedown', { bubbles: true }));
});

expect(document.activeElement).toBe(textarea);
});

it('focuses the rich editor from empty composer space without hijacking actions', async () => {
await renderComposer({ rich: true });
const editor = container.querySelector<HTMLElement>('.rich-text-input')!;
const surface = container.querySelector<HTMLElement>('[data-openbitfun-part="surface"]')!;
const cancelButton = container.querySelector<HTMLButtonElement>('button[aria-label="Cancel"]')!;
editor.blur();

await act(async () => {
surface.dispatchEvent(new dom.window.MouseEvent('mousedown', { bubbles: true }));
});
expect(document.activeElement).toBe(editor);

cancelButton.focus();
await act(async () => {
cancelButton.dispatchEvent(new dom.window.MouseEvent('mousedown', { bubbles: true }));
});
expect(document.activeElement).toBe(cancelButton);
});

it('restores legacy MCP and Skill references as editable capsules', async () => {
const mcpReference = createMcpPromptReference({ serverName: 'node_repl', serverId: 'node_repl' });
const value = `${mcpReference} [$pdf] inspect this`;
const onSubmit = vi.fn();

await act(async () => {
root.render(
<UserMessageEditComposer
value={value}
submitLabel="Save"
cancelLabel="Cancel"
onChange={() => {}}
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
});

const editor = container.querySelector('.rich-text-input');
expect(container.querySelector('textarea')).toBeNull();
expect(editor?.querySelector('[data-inline-token-type="mcp-ref"]')?.textContent).toContain('node_repl');
expect(editor?.querySelector('[data-inline-token-type="skill-ref"]')?.textContent).toContain('pdf');
expect(editor?.textContent).not.toContain('server:');

await act(async () => {
container.querySelector<HTMLButtonElement>('button[aria-label="Save"]')?.click();
});

expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
version: 1,
segments: expect.arrayContaining([
expect.objectContaining({ kind: 'inline-token', tokenType: 'skill', label: 'pdf' }),
]),
}));
});

it('restores and removes reference capsules atomically', async () => {
const onChange = vi.fn();
const onSubmit = vi.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ import {
type RichTextInputElement,
} from '../RichTextInput';
import {
COMPOSER_PRESENTATION_VERSION,
composerPresentationContexts,
withConversationExcerpts,
type ComposerPresentation,
} from '../../utils/composerPresentation';
import { getMcpPromptReferenceMatches } from '../../utils/mcpPromptReference';
import { getSkillPromptReferenceMatches } from '../../utils/skillPromptReference';
import { getAdditionalModePromptReferenceMatches } from '../../utils/additionalModePromptReference';
import { getWidgetPromptReferenceMatches } from '@/tools/generative-widget/widgetPromptReference';

interface UserMessageEditComposerProps {
value: string;
Expand All @@ -32,9 +37,14 @@ interface UserMessageEditComposerProps {
excludeSessionId?: string;
}

type RichUserMessageEditComposerProps = Omit<UserMessageEditComposerProps, 'presentation'> & {
presentation: ComposerPresentation;
};
type RichUserMessageEditComposerProps = UserMessageEditComposerProps;

function hasEditableInlineReference(value: string): boolean {
return getMcpPromptReferenceMatches(value).length > 0
|| getSkillPromptReferenceMatches(value).length > 0
|| getWidgetPromptReferenceMatches(value).length > 0
|| getAdditionalModePromptReferenceMatches(value).length > 0;
}

const RichUserMessageEditComposer: React.FC<RichUserMessageEditComposerProps> = ({
value,
Expand All @@ -54,7 +64,7 @@ const RichUserMessageEditComposer: React.FC<RichUserMessageEditComposerProps> =
const editorRef = useRef<RichTextInputElement>(null);
const contextPickerAnchorRef = useRef<HTMLDivElement>(null);
const [contexts, setContexts] = useState<ContextItem[]>(() => (
composerPresentationContexts(presentation)
presentation ? composerPresentationContexts(presentation) : []
));
const [contextTriggerState, setContextTriggerState] = useState<ContextTriggerState>({
isActive: false,
Expand All @@ -64,16 +74,24 @@ const RichUserMessageEditComposer: React.FC<RichUserMessageEditComposerProps> =
const canSubmit = value.trim().length > 0 && !isSubmitting;

useEffect(() => {
setContexts(composerPresentationContexts(presentation));
setContexts(presentation ? composerPresentationContexts(presentation) : []);
const frame = requestAnimationFrame(() => {
editorRef.current?.restoreComposerPresentation?.(presentation);
if (presentation) {
editorRef.current?.restoreComposerPresentation?.(presentation);
}
editorRef.current?.focus();
});
return () => cancelAnimationFrame(frame);
}, [presentation]);

const capturePresentation = useCallback(() => (
withConversationExcerpts(editorRef.current?.getComposerPresentation?.() ?? presentation, contexts, value)
withConversationExcerpts(
editorRef.current?.getComposerPresentation?.()
?? presentation
?? { version: COMPOSER_PRESENTATION_VERSION, segments: [{ kind: 'text', text: value }] },
contexts,
value,
)
), [contexts, presentation, value]);

const handleSubmit = useCallback(() => {
Expand Down Expand Up @@ -119,6 +137,13 @@ const RichUserMessageEditComposer: React.FC<RichUserMessageEditComposerProps> =
});
}, []);

const handleComposerMouseDown = useCallback((event: React.MouseEvent<HTMLFieldSetElement>) => {
if (isSubmitting) return;
const target = event.target as HTMLElement;
if (target.closest('button, input, textarea, select, a, label, [contenteditable]')) return;
editorRef.current?.focus();
}, [isSubmitting]);

return (
<Composer
className="user-message-edit-composer"
Expand All @@ -127,6 +152,7 @@ const RichUserMessageEditComposer: React.FC<RichUserMessageEditComposerProps> =
data-openbitfun-mode="rich"
data-openbitfun-state={isSubmitting ? 'submitting' : undefined}
disabled={isSubmitting}
onMouseDown={handleComposerMouseDown}
toolbar={(
<ComposerToolbar
className="user-message-edit-composer__actions"
Expand Down Expand Up @@ -267,7 +293,14 @@ export const UserMessageEditComposer: React.FC<UserMessageEditComposerProps> = (
}
}, [handleSubmit, isImeOwnedKey, onCancel]);

if (presentation) {
const handleComposerMouseDown = useCallback((event: React.MouseEvent<HTMLFieldSetElement>) => {
if (isSubmitting) return;
const target = event.target as HTMLElement;
if (target.closest('button, input, textarea, select, a, label, [contenteditable]')) return;
textareaRef.current?.focus();
}, [isSubmitting]);

if (presentation || hasEditableInlineReference(value)) {
return (
<RichUserMessageEditComposer
value={value}
Expand Down Expand Up @@ -295,6 +328,7 @@ export const UserMessageEditComposer: React.FC<UserMessageEditComposerProps> = (
data-openbitfun-mode="plain"
data-openbitfun-state={isSubmitting ? 'submitting' : undefined}
disabled={isSubmitting}
onMouseDown={handleComposerMouseDown}
toolbar={(
<ComposerToolbar
className="user-message-edit-composer__actions"
Expand Down
45 changes: 39 additions & 6 deletions src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@
* its radius so the horizontal edges' tangent points meet that column.
* Padding subtracts the border width to put message text on the same axis.
*/
width: auto;
width: fit-content;
max-width: min(72%, 48rem);
padding: 0.46rem var(--_user-message-padding-inline);
background: var(--openbitfun-color-action-quiet-hover);
border: var(--_user-message-border-width) solid color-mix(in srgb, var(--openbitfun-color-border-default) 72%, transparent);
border-radius: var(--_user-message-radius);
margin-block: 0;
margin-inline: calc(-1 * var(--_user-message-radius));
margin-inline: auto calc(-1 * var(--_user-message-radius));
box-shadow: none;
transition: opacity 140ms ease;
/*
Expand All @@ -52,6 +53,12 @@

}

.user-message-item--editing {
width: auto;
max-width: none;
margin-inline: calc(-1 * var(--_user-message-radius));
}

.user-message-item__timestamp {
/* The timestamp shares the external meta row with the action cluster. */
color: var(--openbitfun-color-content-muted);
Expand Down Expand Up @@ -235,13 +242,37 @@
background: var(--openbitfun-color-status-success-surface);
}

.user-message-item__edit-layout {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--openbitfun-control-flow-chat-inline-gap);
}

.user-message-edit-composer {
width: 100%;

// The surrounding user-message bubble already communicates the editing
// boundary. Keep Composer's layout, but avoid stacking its border, shadow,
// and focus ring into a visually heavy double frame.
> [data-openbitfun-part='surface'],
> [data-openbitfun-part='surface']:focus-within {
min-block-size: 0;
gap: var(--openbitfun-control-flow-chat-inline-gap);
padding: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
}

> [data-openbitfun-part='surface'] > [data-openbitfun-part='editor'] {
padding: 0;
}
}

.user-message-edit-composer__textarea {
width: 100%;
min-height: 5.5rem;
min-height: var(--openbitfun-control-height-sm);
max-height: 45vh;
overflow-y: auto;
font-size: var(--openbitfun-type-flow-control-font-size);
Expand All @@ -250,12 +281,12 @@

.user-message-edit-composer__rich-input {
position: relative;
min-height: 5.5rem;
min-height: var(--openbitfun-control-height-sm);
max-height: 45vh;
overflow-y: auto;

.rich-text-input {
min-height: 4.4rem;
min-height: var(--openbitfun-control-height-sm);
padding: 0;
border: none;
background: transparent;
Expand Down Expand Up @@ -290,8 +321,10 @@
.user-message-item__images {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
margin-top: 6px;
margin-block: 0 6px;
margin-inline-end: calc(-1 * var(--_user-message-radius));
}

.user-message-item__image-thumb {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,84 @@ describe('UserMessageItem steering tag', () => {
expect(document.querySelector('.user-message-item__lightbox')).toBeNull();
});

it('keeps image attachments outside the compact editor while editing', async () => {
activeSessionRef.current = {
sessionId: 'main-session',
sessionKind: 'normal',
dialogTurns: [{ id: 'turn-1', status: 'completed' }],
};

act(() => root.render(
<FlowChatContext.Provider
value={{
sessionId: 'main-session',
allowUserMessageEdit: true,
allowUserMessageRollback: true,
}}
>
<UserMessageItem
message={{
id: 'image-edit',
content: 'Describe this image',
timestamp: 1000,
images: [{
id: 'image-1',
name: 'preview.png',
dataUrl: 'data:image/png;base64,AA==',
mimeType: 'image/png',
}],
}}
turnId="turn-1"
/>
</FlowChatContext.Provider>,
));

await act(async () => {
container.querySelector<HTMLButtonElement>('.user-message-item__edit-btn')?.click();
});

const shell = container.querySelector('.user-message-item-shell');
const imageGallery = shell?.querySelector('.user-message-item__images');
const bubble = shell?.querySelector('.user-message-item--editing');
const editLayout = bubble?.querySelector('.user-message-item__edit-layout');
const editComposer = editLayout?.querySelector('.user-message-edit-composer__icon-button--confirm');

expect(shell?.firstElementChild).toBe(imageGallery);
expect(imageGallery?.nextElementSibling).toBe(bubble);
expect(editLayout?.contains(imageGallery ?? null)).toBe(false);
expect(editLayout?.firstElementChild).toBe(editComposer);
expect(container.querySelectorAll('.user-message-item__images')).toHaveLength(1);
});

it('places sent image attachments above the right-aligned text bubble', () => {
act(() => root.render(
<FlowChatContext.Provider value={{ allowUserMessageEdit: false, allowUserMessageRollback: false }}>
<UserMessageItem
message={{
id: 'sent-image',
content: 'Describe this image',
timestamp: 1000,
images: [{
id: 'image-1',
name: 'preview.png',
dataUrl: 'data:image/png;base64,AA==',
mimeType: 'image/png',
}],
}}
turnId="turn-1"
/>
</FlowChatContext.Provider>,
));

const shell = container.querySelector('.user-message-item-shell');
const imageGallery = shell?.querySelector('.user-message-item__images');
const bubble = shell?.querySelector('.user-message-item');

expect(shell?.firstElementChild).toBe(imageGallery);
expect(imageGallery?.nextElementSibling).toBe(bubble);
expect(bubble?.classList.contains('user-message-item--editing')).toBe(false);
});

it('renders pending steering tag on the right side of the message row', () => {
act(() => {
root.render(
Expand Down
Loading
Loading