From 2b5c2b5c639ac2165c42a3666e8e0aff9be81806 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 29 Oct 2026 20:42:58 +0800 Subject: [PATCH 1/2] fix(flow-chat): fit collapsed user bubbles to visible preview lines --- .../src/generated/chat-styles.css | 20 +++++++ .../modern/FLOWCHAT_VERIFICATION.md | 11 +++- .../modern/FLOWCHAT_VIRTUALIZATION.md | 21 +++++-- .../components/modern/UserMessageItem.scss | 24 ++++++++ .../modern/UserMessageItem.test.tsx | 35 ++++++++++++ .../components/modern/UserMessageItem.tsx | 32 ++++++++--- .../modern/userMessagePreview.test.ts | 57 +++++++++++++++++++ .../components/modern/userMessagePreview.ts | 37 ++++++++++++ 8 files changed, 221 insertions(+), 16 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts diff --git a/src/apps/miniapp-shell/src/generated/chat-styles.css b/src/apps/miniapp-shell/src/generated/chat-styles.css index 4d7ede8d85..fd426d2817 100644 --- a/src/apps/miniapp-shell/src/generated/chat-styles.css +++ b/src/apps/miniapp-shell/src/generated/chat-styles.css @@ -2101,6 +2101,10 @@ position: relative; } +.user-message-item:not(.user-message-item--expanded):not(.user-message-item--editing) { + max-width: min(28rem, var(--openbitfun-control-flow-chat-user-message-max-width)); +} + .user-message-item__surface { position: absolute; inset: 0; @@ -2276,6 +2280,22 @@ transition: none; } +.user-message-item__content-preview::after { + content: "…"; +} + +.user-message-item__content-overflow { + display: none; +} + +.user-message-item--expanded .user-message-item__content-preview::after { + content: none; +} + +.user-message-item--expanded .user-message-item__content-overflow { + display: contents; +} + .user-message-item--expanded { max-height: 60vh; overflow-y: auto; diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index b03a3f234b..edae1fba65 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -31,7 +31,7 @@ each missing what the other had. | `../../selection/conversationExcerptEditing.test.ts` | draft/visible/queue edit and delete ownership, preserved prompt context and attachments, legacy payload handling, sending guards and sent snapshots with no mutation capability | | `../../services/flow-chat-manager/PendingQueueModule.test.ts` | persisted payload edits preserve queue order/identity, images and device isolation; sending entries reject edits | | `../../../infrastructure/markdown/useStreamingTextReveal.test.tsx` | appended-glyph-only fading, independent batch clocks and renderers, history/remount stability, stream completion and reduced motion | -| `UserMessageItem.test.tsx` | sent and reloaded MCP references, mixed reference capsules, failed-message presentation, shared edit/rollback menu, Escape focus return, disabled reasons, and existing message action flows; local DOM fixtures, not remote transport evidence | +| `UserMessageItem.test.tsx`, `userMessagePreview.test.ts` | preview-only layout with complete text/line endings/reference capsules preserved, expansion/collapse and full-message copy, sent and reloaded MCP references, failed-message presentation, shared edit/rollback menu, Escape focus return, disabled reasons, and existing message action flows; local DOM/CSS fixtures, not native width or remote transport evidence | | `UserMessageItemActions.test.ts` | metadata visibility, bubble outsets from the shared reading column, and normal-flow action layout | | `../../services/submittedMessagePresentation.test.ts` | explicit-send receipts, one-shot claims, expiry, device activation isolation, and initial status paint delay | | `useSubmittedMessageMotion.test.tsx` | submission clock, remount stability, StrictMode, reduced motion, focus, and surface-change cancellation; lifecycle only, not visual acceptance | @@ -134,6 +134,15 @@ group does not renumber the others. ### Submitting and revealing +For user-message disclosure, compare a short message with a long message whose +first lines are short and later lines are wide. The collapsed preview stays +compact; expanding grows leftward only as needed and downward, keeping its right +edge aligned. Collapse restores the compact width. Repeat with images, reference +capsules, failed messages, editing and a narrow main/side pane. Verify long +expanded text still scrolls within its height limit. Local, remote-workspace, +Peer Device and detached-dispatch observer views require separate real-host +checks; mobile Remote Control uses its own message renderer. + 1. A newly submitted Turn performs one physical-bottom placement, exposing the full resident spacer while leaving at least one quarter of the transcript visible above the input footer. diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 53962ba64d..8776843644 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -416,12 +416,21 @@ embedded transcripts without a rail keep their own layout. The scroller remains full width. The shared content-padding token defaults to 0.75rem on wide and narrow surfaces, keeping the reading column compact while leaving room for decoration and targets. -The borderless bubble extends into that gutter by its corner radius, and its inner -padding matches that radius so user text, timestamps, reply prose, and completion -metadata keep the same content axis. Content-fit bubbles have an 8rem minimum that -is capped by the 72% message-width limit on narrow surfaces. Content below that -minimum stays shrink-wrapped and centered; content that reaches the available line -width retains leading text alignment and wraps normally. +The borderless bubble ends at the reading column's trailing content edge, with +inner padding matching its corner radius. Bubbles fit their content without a +minimum width. Collapsed previews are capped at 28rem and the shared message-width +limit (72% of the column, up to 48rem by default). Text after the third explicit +line break is kept in a span outside layout while collapsed, so hidden long lines +do not contribute to intrinsic width. The visible prefix sizes the bubble; CSS +still clamps wrapped text to three visual lines. A generated ellipsis marks the +hidden suffix without changing the rendered-text coordinate sequence. Reference +capsules stay intact, and copy/edit continue to use the full source. +Expanding restores the suffix and removes the preview cap: the bubble grows +leftward as needed from the same trailing edge and downward up to its existing +60vh scroll limit. Editing retains the full reading-column width. Narrow columns +remain bounded by the shared width limit. +These changes use normal layout and the existing resize measurement path, with +no layout animation or viewport writes. Both metadata rows align the last icon frame with the content's trailing edge, retaining 28px hit targets and the same compact action gap. Each action cluster wraps as a whole when space is limited. A half-space-1 gap groups user metadata diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss index f389a3f6b4..f4725c40f8 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss @@ -40,6 +40,12 @@ position: relative; } +.user-message-item:not(.user-message-item--expanded):not(.user-message-item--editing) { + // Wrapped previews stay compact; explicit lines beyond the preview leave + // layout below, so shorter visible lines determine the content-fit width. + max-width: min(28rem, var(--openbitfun-control-flow-chat-user-message-max-width)); +} + .user-message-item__surface { position: absolute; inset: 0; @@ -201,6 +207,24 @@ transition: none; } +// Keep the full text in DOM order for selection offsets and source references, +// but remove hidden lines from intrinsic sizing. The ellipsis is paint only. +.user-message-item__content-preview::after { + content: '\2026'; +} + +.user-message-item__content-overflow { + display: none; +} + +.user-message-item--expanded .user-message-item__content-preview::after { + content: none; +} + +.user-message-item--expanded .user-message-item__content-overflow { + display: contents; +} + // Expanded state (outer scroll area). .user-message-item--expanded { max-height: 60vh; 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 0f8572f34c..dc53af7c28 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 @@ -2,6 +2,8 @@ import React, { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { JSDOM } from 'jsdom'; +import { fileURLToPath } from 'node:url'; +import { compile } from 'sass'; import { FlowChatContext } from './FlowChatContext'; import { UserMessageItem } from './UserMessageItem'; @@ -275,6 +277,39 @@ describe('UserMessageItem steering tag', () => { vi.unstubAllGlobals(); }); + it.each(['completed', 'error'] as const)('sizes only the preview while retaining complete %s message text', async turnStatus => { + const stylesheet = document.createElement('style'); + stylesheet.textContent = compile(fileURLToPath(new URL('./UserMessageItem.scss', import.meta.url))).css; + document.head.append(stylesheet); + const preview = 'A short introduction\n\nA short question'; + const hidden = '\nA much longer explanation that must not widen the preview.'; + const text = preview + hidden; + act(() => root.render( + + + , + )); + + const content = container.querySelector('[data-testid="chat-user-message-content"]')!; + const tail = content.querySelector('.user-message-item__content-overflow')!; + expect(content.querySelector('.user-message-item__content-preview')?.textContent).toBe(preview); + expect(tail.textContent).toBe(hidden); + expect(dom.window.getComputedStyle(tail).display).toBe('none'); + expect(content.textContent).toBe(text); + expect(content.title).toBe('message.clickToExpand'); + + act(() => content.click()); + expect(dom.window.getComputedStyle(tail).display).toBe('contents'); + expect(content.textContent).toBe(text); + expect(content.title).toBe('message.clickToCollapse'); + + act(() => content.click()); + expect(dom.window.getComputedStyle(tail).display).toBe('none'); + await act(async () => container.querySelector('.user-message-item__copy-btn')!.click()); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(text); + }); + it('uses the shared image dialog without changing image or background click behavior', () => { 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 41ab39137a..1ab28192e8 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -59,7 +59,8 @@ import { import { restoreImageContextsFromPayload } from '../../utils/imageContextRestoration'; import { writeComposerClipboardPayload } from '../../utils/composerClipboard'; import { buildImagePayload } from '../../utils/imagePayload'; -import { UserMessagePresentationContent, UserMessageTextContent } from './UserMessagePresentationContent'; +import { UserMessagePresentationContent } from './UserMessagePresentationContent'; +import { splitUserMessagePreview } from './userMessagePreview'; import { UserMessageImage } from './UserMessageImage'; import { useSubmittedMessageMotion } from './useSubmittedMessageMotion'; import { finishSubmittedMessagePreview } from '../../services/submittedMessagePresentation'; @@ -335,6 +336,11 @@ export const UserMessageItem = React.memo( const copyTokens = composerPresentation ? composerPresentationToClipboardText(composerPresentation) : messageContent; + + const messagePreview = useMemo(() => splitUserMessagePreview(composerPresentation ?? { + version: 1, + segments: [{ kind: 'text', text: displayText }], + }), [composerPresentation, displayText]); // Check whether content overflows. Uses the shared ResizeObserver instead // of a per-message window resize listener: observer callbacks run after @@ -348,7 +354,8 @@ export const UserMessageItem = React.memo( const checkOverflow = () => { // Detect truncated text. - const isOverflowing = element.scrollHeight > element.clientHeight || + const isOverflowing = messagePreview.overflow !== null || + element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth; setHasOverflow(isOverflowing); }; @@ -361,7 +368,7 @@ export const UserMessageItem = React.memo( // window-resize handler, which re-read the ref lazily on every event), so // the effect must re-run on those transitions or it keeps observing a // detached node and never observes the live one. - }, [composerPresentation, displayText, expanded, isEditing, isFailed]); + }, [messagePreview, expanded, isEditing, isFailed]); // Copy the user message. const handleCopy = useCallback(async (e: React.MouseEvent) => { @@ -633,6 +640,17 @@ export const UserMessageItem = React.memo( ) : null; + const messageTextContent = messagePreview.overflow ? ( + <> + + + + + + + + ) : ; + return (
{messageImageGallery} @@ -697,9 +715,7 @@ export const UserMessageItem = React.memo( }} > - {composerPresentation ? ( - - ) : } + {messageTextContent}
{steeringTag && (
@@ -722,9 +738,7 @@ export const UserMessageItem = React.memo( cursor: (hasOverflow || expanded) ? 'pointer' : 'text', }} > - {composerPresentation ? ( - - ) : } + {messageTextContent}
{steeringTag && (
diff --git a/src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts new file mode 100644 index 0000000000..d37b2b0c54 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import type { ComposerPresentation } from '../../utils/composerPresentation'; +import { splitUserMessagePreview } from './userMessagePreview'; + +const textPresentation = (text: string): ComposerPresentation => ({ + version: 1, + segments: [{ kind: 'text', text }], +}); + +describe('user message preview', () => { + it('excludes long hidden lines from a short preview with a blank paragraph', () => { + const text = 'A short introduction\n\nA short question\nA much longer explanation follows here.'; + const source = textPresentation(text); + const { preview, overflow } = splitUserMessagePreview(source); + + expect(preview).toEqual(textPresentation('A short introduction\n\nA short question')); + expect(overflow).toEqual(textPresentation('\nA much longer explanation follows here.')); + expect(source).toEqual(textPresentation(text)); + }); + + it.each(['\n', '\r\n', '\r'])('preserves %j line endings in the complete text', separator => { + const text = ['One', 'Two', 'Three', 'Four'].join(separator); + const { preview, overflow } = splitUserMessagePreview(textPresentation(text)); + expect(preview).toEqual(textPresentation(['One', 'Two', 'Three'].join(separator))); + expect(overflow).toEqual(textPresentation(`${separator}Four`)); + }); + + it.each(['Short message', 'One\nTwo\nThree', 'A long wrapping paragraph. '.repeat(200)])( + 'leaves up to three explicit lines to the existing visual clamp', text => { + const source = textPresentation(text); + expect(splitUserMessagePreview(source)).toEqual({ preview: source, overflow: null }); + }, + ); + + it('counts line breaks across text segments without splitting reference capsules', () => { + const skill = { kind: 'inline-token', token: '[$pdf]', tokenType: 'skill', label: 'pdf' } as const; + const context = { + kind: 'context', + context: { id: 'file-1', type: 'file', filePath: '/project/input.txt', timestamp: 1 }, + tag: '@input.txt', label: 'input.txt', title: '/project/input.txt', + } as const; + const source: ComposerPresentation = { + version: 1, + segments: [ + { kind: 'text', text: 'Review\n' }, skill, + { kind: 'text', text: '\nThen read\n' }, context, + ], + }; + const original = JSON.stringify(source); + const { preview, overflow } = splitUserMessagePreview(source); + expect(preview.segments).toEqual([ + { kind: 'text', text: 'Review\n' }, skill, { kind: 'text', text: '\nThen read' }, + ]); + expect(overflow?.segments).toEqual([{ kind: 'text', text: '\n' }, context]); + expect(JSON.stringify(source)).toBe(original); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts new file mode 100644 index 0000000000..08f367acc9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts @@ -0,0 +1,37 @@ +import type { ComposerPresentation, ComposerPresentationSegment } from '../../utils/composerPresentation'; + +/** + * CSS clamps wrapped text to three visual lines, but still sizes it using every + * hidden line. Separate everything after the third explicit line break so that + * it can leave layout while preserving the complete rendered-text sequence. + */ +export function splitUserMessagePreview(presentation: ComposerPresentation): { + preview: ComposerPresentation; + overflow: ComposerPresentation | null; +} { + const segments: ComposerPresentationSegment[] = []; + let remainingBreaks = 3; + + for (const [index, segment] of presentation.segments.entries()) { + if (segment.kind === 'text') { + for (const lineBreak of segment.text.matchAll(/\r\n|[\r\n]/g)) { + if (--remainingBreaks === 0) { + segments.push({ ...segment, text: segment.text.slice(0, lineBreak.index) }); + return { + preview: { ...presentation, segments }, + overflow: { + ...presentation, + segments: [ + { ...segment, text: segment.text.slice(lineBreak.index) }, + ...presentation.segments.slice(index + 1), + ], + }, + }; + } + } + } + segments.push(segment); + } + + return { preview: presentation, overflow: null }; +} From 322b49d6ce33af5b87aace7535b4e4a4720c5872 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 29 Oct 2026 20:46:58 +0800 Subject: [PATCH 2/2] fix(flow-chat): preserve split Windows line endings in previews --- .../modern/userMessagePreview.test.ts | 26 +++++++++++++++++++ .../components/modern/userMessagePreview.ts | 7 +++++ 2 files changed, 33 insertions(+) diff --git a/src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts index d37b2b0c54..f7d8419fd0 100644 --- a/src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.test.ts @@ -54,4 +54,30 @@ describe('user message preview', () => { expect(overflow?.segments).toEqual([{ kind: 'text', text: '\n' }, context]); expect(JSON.stringify(source)).toBe(original); }); + + it('treats a Windows line ending split across text segments as one break', () => { + const source: ComposerPresentation = { + version: 1, + segments: ['One\r', '', '\nTwo\r', '\nThree\r', '\nFour'].map(text => ({ kind: 'text', text })), + }; + const { preview, overflow } = splitUserMessagePreview(source); + const text = (presentation: ComposerPresentation | null) => presentation?.segments.map( + segment => segment.kind === 'text' ? segment.text : '', + ).join(''); + expect(text(preview)).toBe('One\r\nTwo\r\nThree'); + expect(text(overflow)).toBe('\r\nFour'); + }); + + it('keeps line breaks separated by a capsule distinct', () => { + const skill = { kind: 'inline-token', token: '[$pdf]', tokenType: 'skill', label: 'pdf' } as const; + const source: ComposerPresentation = { + version: 1, + segments: [{ kind: 'text', text: 'One\r' }, skill, { kind: 'text', text: '\nThree\nFour' }], + }; + const { preview, overflow } = splitUserMessagePreview(source); + expect(preview.segments).toEqual([ + { kind: 'text', text: 'One\r' }, skill, { kind: 'text', text: '\nThree' }, + ]); + expect(overflow).toEqual(textPresentation('\nFour')); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts index 08f367acc9..60103ddb67 100644 --- a/src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts +++ b/src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts @@ -11,10 +11,14 @@ export function splitUserMessagePreview(presentation: ComposerPresentation): { } { const segments: ComposerPresentationSegment[] = []; let remainingBreaks = 3; + let previousTextEndsWithCR = false; for (const [index, segment] of presentation.segments.entries()) { if (segment.kind === 'text') { for (const lineBreak of segment.text.matchAll(/\r\n|[\r\n]/g)) { + // Persisted presentation segments need not coincide with line endings. + // A CR followed by LF in the next text segment is still one break. + if (previousTextEndsWithCR && lineBreak.index === 0 && lineBreak[0] === '\n') continue; if (--remainingBreaks === 0) { segments.push({ ...segment, text: segment.text.slice(0, lineBreak.index) }); return { @@ -29,6 +33,9 @@ export function splitUserMessagePreview(presentation: ComposerPresentation): { }; } } + if (segment.text) previousTextEndsWithCR = segment.text.endsWith('\r'); + } else { + previousTextEndsWithCR = false; } segments.push(segment); }