From 91599a0a9a039c0bd4ea64c76182434eb1e45632 Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Thu, 17 Sep 2026 21:00:39 +0800 Subject: [PATCH] fix(web): refine sent message editing layout --- .../modern/UserMessageEditComposer.test.tsx | 70 +++++++++++++++++ .../modern/UserMessageEditComposer.tsx | 50 ++++++++++-- .../components/modern/UserMessageItem.scss | 45 +++++++++-- .../modern/UserMessageItem.test.tsx | 78 +++++++++++++++++++ .../components/modern/UserMessageItem.tsx | 66 +++++++++------- .../modern/UserMessageItemActions.test.ts | 28 ++++++- 6 files changed, 293 insertions(+), 44 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx index fdbbdccf02..f36e73f2ce 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.test.tsx @@ -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, @@ -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('[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('.rich-text-input')!; + const surface = container.querySelector('[data-openbitfun-part="surface"]')!; + const cancelButton = container.querySelector('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( + {}} + 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('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(); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx index b94980fc73..7a24ac1bb5 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageEditComposer.tsx @@ -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; @@ -32,9 +37,14 @@ interface UserMessageEditComposerProps { excludeSessionId?: string; } -type RichUserMessageEditComposerProps = Omit & { - 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 = ({ value, @@ -54,7 +64,7 @@ const RichUserMessageEditComposer: React.FC = const editorRef = useRef(null); const contextPickerAnchorRef = useRef(null); const [contexts, setContexts] = useState(() => ( - composerPresentationContexts(presentation) + presentation ? composerPresentationContexts(presentation) : [] )); const [contextTriggerState, setContextTriggerState] = useState({ isActive: false, @@ -64,16 +74,24 @@ const RichUserMessageEditComposer: React.FC = 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(() => { @@ -119,6 +137,13 @@ const RichUserMessageEditComposer: React.FC = }); }, []); + const handleComposerMouseDown = useCallback((event: React.MouseEvent) => { + 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 ( = data-openbitfun-mode="rich" data-openbitfun-state={isSubmitting ? 'submitting' : undefined} disabled={isSubmitting} + onMouseDown={handleComposerMouseDown} toolbar={( = ( } }, [handleSubmit, isImeOwnedKey, onCancel]); - if (presentation) { + const handleComposerMouseDown = useCallback((event: React.MouseEvent) => { + 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 ( = ( data-openbitfun-mode="plain" data-openbitfun-state={isSubmitting ? 'submitting' : undefined} disabled={isSubmitting} + onMouseDown={handleComposerMouseDown} toolbar={( [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); @@ -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; @@ -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 { diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index 72a4a71b54..e0d24bad4a 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -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( + + + , + )); + + await act(async () => { + container.querySelector('.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( + + + , + )); + + 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( diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 12eae85a8a..feb18784f1 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -534,39 +534,55 @@ export const UserMessageItem = React.memo( ); } + + const messageImageGallery = messageImages.length > 0 ? ( +
+ {messageImages.map(img => ( + + ))} +
+ ) : null; return (
+ {messageImageGallery} +
{isEditing ? ( - +
+ +
) : (
( > {isFailed ? (
-
(
) : ( <> -
(
)} - {message.images && message.images.length > 0 && ( -
- {message.images.map(img => ( - - ))} -
- )} - {lightboxImage && ( { + it('keeps image editing vertically ordered and compact', () => { + const stylesheet = readFileSync( + fileURLToPath(new URL('./UserMessageItem.scss', import.meta.url)), + 'utf8', + ).replace(/\r\n?/g, '\n'); + const editLayout = extractBlock(stylesheet, '\n.user-message-item__edit-layout {'); + const composer = extractBlock(stylesheet, '\n.user-message-edit-composer {'); + const textarea = extractBlock(stylesheet, '\n.user-message-edit-composer__textarea {'); + const richInput = extractBlock(stylesheet, '\n.user-message-edit-composer__rich-input {'); + const bubble = extractBlock(stylesheet, '\n.user-message-item {'); + const editingBubble = extractBlock(stylesheet, '\n.user-message-item--editing {'); + + expect(editLayout).toContain('flex-direction: column;'); + expect(editLayout).toContain('gap: var(--openbitfun-control-flow-chat-inline-gap);'); + expect(composer).toContain('min-block-size: 0;'); + expect(composer).toContain('padding: 0;'); + expect(textarea).toContain('min-height: var(--openbitfun-control-height-sm);'); + expect(richInput).toContain('min-height: var(--openbitfun-control-height-sm);'); + expect(bubble).toContain('width: fit-content;'); + expect(bubble).toContain('max-width: min(72%, 48rem);'); + expect(editingBubble).toContain('width: auto;'); + expect(editingBubble).toContain('max-width: none;'); + expect(stylesheet).not.toContain('.user-message-item__images--editing'); + expect(stylesheet).not.toContain('min-height: 5.5rem;'); + }); + it('shows the copy, edit, and rollback actions as one always-available cluster', () => { const stylesheet = readFileSync( fileURLToPath(new URL('./UserMessageItem.scss', import.meta.url)), @@ -66,7 +92,7 @@ describe('UserMessageItem metadata visibility', () => { const bubble = extractBlock(stylesheet, '\n.user-message-item {'); expect(bubble).toContain('padding: 0.46rem var(--_user-message-padding-inline);'); expect(shell).toContain('--_user-message-padding-inline: max(0px, calc(var(--_user-message-radius) - var(--_user-message-border-width)));'); - expect(bubble).toContain('margin-inline: calc(-1 * var(--_user-message-radius));'); + expect(bubble).toContain('margin-inline: auto calc(-1 * var(--_user-message-radius));'); expect(meta).toContain('padding-inline: 0;'); expect(meta).toContain('padding-block: calc(var(--openbitfun-space-1) / 2) 0;'); expect(meta).toContain('pointer-events: auto;');