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
20 changes: 20 additions & 0 deletions src/apps/miniapp-shell/src/generated/chat-styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<FlowChatContext.Provider value={{ allowUserMessageRollback: false }}>
<UserMessageItem message={{ id: 'long-message', content: text, timestamp: 1000 }}
turnId="long-turn" turnStatus={turnStatus} />
</FlowChatContext.Provider>,
));

const content = container.querySelector<HTMLElement>('[data-testid="chat-user-message-content"]')!;
const tail = content.querySelector<HTMLElement>('.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<HTMLButtonElement>('.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(
<FlowChatContext.Provider value={{ sessionId: 'main-session' }}>
Expand Down
32 changes: 23 additions & 9 deletions src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -335,6 +336,11 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
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
Expand All @@ -348,7 +354,8 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(

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);
};
Expand All @@ -361,7 +368,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
// 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) => {
Expand Down Expand Up @@ -633,6 +640,17 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
</div>
) : null;

const messageTextContent = messagePreview.overflow ? (
<>
<span className="user-message-item__content-preview">
<UserMessagePresentationContent presentation={messagePreview.preview} />
</span>
<span className="user-message-item__content-overflow">
<UserMessagePresentationContent presentation={messagePreview.overflow} />
</span>
</>
) : <UserMessagePresentationContent presentation={messagePreview.preview} />;

return (
<div className="user-message-item-shell" ref={shellRef}>
{messageImageGallery}
Expand Down Expand Up @@ -697,9 +715,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
}}
>

{composerPresentation ? (
<UserMessagePresentationContent presentation={composerPresentation} />
) : <UserMessageTextContent text={displayText} />}
{messageTextContent}
</div>
{steeringTag && (
<div className={`user-message-item__steering-tag ${steeringTag.className}`} data-openbitfun-product-component="user-message-item" data-openbitfun-product-part="steeringTag">
Expand All @@ -722,9 +738,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
cursor: (hasOverflow || expanded) ? 'pointer' : 'text',
}}
>
{composerPresentation ? (
<UserMessagePresentationContent presentation={composerPresentation} />
) : <UserMessageTextContent text={displayText} />}
{messageTextContent}
</div>
{steeringTag && (
<div className={`user-message-item__steering-tag ${steeringTag.className}`} data-openbitfun-product-component="user-message-item" data-openbitfun-product-part="steeringTag">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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);
});

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'));
});
});
44 changes: 44 additions & 0 deletions src/web-ui/src/flow_chat/components/modern/userMessagePreview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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;
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 {
preview: { ...presentation, segments },
overflow: {
...presentation,
segments: [
{ ...segment, text: segment.text.slice(lineBreak.index) },
...presentation.segments.slice(index + 1),
],
},
};
}
}
if (segment.text) previousTextEndsWithCR = segment.text.endsWith('\r');
} else {
previousTextEndsWithCR = false;
}
segments.push(segment);
}

return { preview: presentation, overflow: null };
}
Loading