-
- );
-};
-
-export default EditTextPart;
diff --git a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx
index 4361481d54..00c50f8e0a 100644
--- a/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx
+++ b/client/src/components/Chat/Messages/Content/Parts/SteerPart.tsx
@@ -1,26 +1,20 @@
import { memo, useMemo, useState, useCallback } from 'react';
-import { useAtomValue } from 'jotai';
import { useRecoilValue } from 'recoil';
-import { InfoHoverCard, ESide, UserIcon } from '@librechat/client';
+import { InfoHoverCard, ESide } from '@librechat/client';
import type { TFile, TMessage } from 'librechat-data-provider';
-import type { TMessageIcon } from '~/common';
import FilePreviewDialog from '~/components/Chat/Messages/Content/FilePreviewDialog';
import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
-import MessageIcon from '~/components/Chat/Messages/MessageIcon';
import Image from '~/components/Chat/Messages/Content/Image';
-import { fontSizeAtom } from '~/store/fontSize';
import { useShareContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
-const USER_ICON: TMessageIcon = { isCreatedByUser: true };
-
/**
* A mid-run steering message rendered as a standard user message inside the
- * assistant response — same icon, author header, and text presentation as any
+ * assistant response, with the same compact surface and text presentation as any
* user turn, placed where the words enter the run so the visible order equals
* what the next turn replays (`ContentTypes.STEER` splits back into a
* HumanMessage server-side). Only the server-applied part renders here, at its
@@ -43,7 +37,6 @@ const SteerPart = memo(function SteerPart({
/** Read the atom rather than the auth context: AuthContextProvider mirrors the
* user into it, and the public share route mounts outside that provider. */
const user = useRecoilValue(store.user);
- const fontSize = useAtomValue(fontSizeAtom);
const { isSharedConvo } = useShareContext();
const usernameDisplay = useRecoilValue(store.UsernameDisplay);
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
@@ -82,51 +75,12 @@ const SteerPart = memo(function SteerPart({
return (
-
-
- {isSharedConvo === true ? (
- /** The atom still holds the viewer's identity when a signed-in user opens
- * a share link, so rendering the identity-bearing avatar here would put
- * the viewer's face on the sharer's steer. Mirrors Share/MessageIcon. */
-
-
-
- ) : (
-
- )}
-
-
-
-
- {label}
- {/* Subtle "?" explaining why a user message appears inside the
- * response. Like the message hover buttons, it's revealed on
- * hover/focus on hover-capable pointers, but stays visible on
- * touch (no hover to reveal it) via [@media(hover:hover)]:opacity-0. */}
-
-
-
-
-
{otherFiles.length > 0 && (
({
default: ({ altText }: { altText: string }) => ,
}));
-/** Seeds the user atom rather than mocking `useAuthContext`, and renders the real
- * MessageIcon tree — mocking either one hid a crash on the share route, where
- * neither an auth context nor a user exists. */
+/** Seeds the user atom rather than mocking `useAuthContext`, matching the share
+ * route where neither an auth context nor a user exists. */
const SEEDED_USER = { name: 'Danny', username: 'danny' };
function renderPart(
@@ -58,11 +56,9 @@ function renderPart(
user: { name: string; username: string } | null = SEEDED_USER,
) {
return render(
-
- user && set(store.user, user as never)}>
-
-
- ,
+ user && set(store.user, user as never)}>
+
+ ,
);
}
@@ -89,10 +85,10 @@ describe('SteerPart author label', () => {
expect(screen.getByText('com_user_message')).toBeInTheDocument();
});
- it('never renders the viewer identity on a shared steer avatar', () => {
+ it('never renders the viewer identity on a shared steer bubble', () => {
/** The user atom is app-wide and survives navigation, so a signed-in viewer
* opening a share link still has an identity in state. The shared steer must
- * show the generic avatar regardless. */
+ * keep generic attribution regardless. */
mockShareContext = { isSharedConvo: true, shareId: 'share-1' };
renderPart(undefined, SEEDED_USER);
@@ -130,12 +126,13 @@ describe('SteerPart presentation', () => {
mockShareContext = {};
});
- it('presents the steer as a user message with an icon', () => {
+ it('presents the steer as a compact user bubble with accessible attribution', () => {
renderPart();
- /** Asserts the real avatar rather than a stubbed one — the previous mock was
- * what hid the auth-context crash inside this icon tree. */
- expect(screen.getByTitle('Danny')).toBeInTheDocument();
- expect(screen.getByText('steered words')).toBeInTheDocument();
+ const message = screen.getByText('steered words');
+
+ expect(message.closest('.bg-surface-tertiary')).toHaveClass('rounded-theme-surface');
+ expect(screen.getByRole('heading', { name: 'Danny' })).toHaveClass('sr-only');
+ expect(screen.queryByTitle('Danny')).not.toBeInTheDocument();
});
it('anchors the steer for the message-nav rail', () => {
diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts
index 68da02ac1b..527654a442 100644
--- a/client/src/components/Chat/Messages/Content/Parts/index.ts
+++ b/client/src/components/Chat/Messages/Content/Parts/index.ts
@@ -8,7 +8,6 @@ export { default as LogContent } from './LogContent';
export { default as ExecuteCode } from './ExecuteCode';
export { default as Summary } from './Summary';
export { default as AgentUpdate } from './AgentUpdate';
-export { default as EditTextPart } from './EditTextPart';
export { default as SkillCall } from './SkillCall';
export { default as ReadFileCall } from './ReadFileCall';
export { default as FileAuthoringCall } from './FileAuthoringCall';
diff --git a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx
index fa47cd8f78..9d9d1a5830 100644
--- a/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx
+++ b/client/src/components/Chat/Messages/Content/__tests__/ContentParts.integration.test.tsx
@@ -78,7 +78,6 @@ jest.mock('../Parts', () => ({
Reasoning: () => ,
Summary: () => ,
Text: ({ text }: { text?: string }) =>
+ }
+ label={name}
+ timestamp={message.createdAt ?? message.clientTimestamp}
+ ariaLabel={getMessageAriaLabel(message, localize)}
+ headerPrefix={getHeaderPrefixForScreenReader(message, localize)}
+ isCreatedByUser={isCreatedByUser === true}
+ hasParallelContent={hasParallelContent}
+ fullWidth={maximizeChatSpace}
+ isEditing={edit}
+ footer={
+
+ {/* While the answer is generating every other action is withheld, which
+ would otherwise leave this counter sitting alone under a half-written
+ response. It reveals on hover there, like the actions it sits with. */}
+
-
diff --git a/client/src/components/Share/Message.tsx b/client/src/components/Share/Message.tsx
index a4442f62c5..36e3db67c9 100644
--- a/client/src/components/Share/Message.tsx
+++ b/client/src/components/Share/Message.tsx
@@ -1,20 +1,19 @@
-import { useAtomValue } from 'jotai';
import type { TMessageProps } from '~/common';
import AuthorHeader from '~/components/Chat/Messages/Content/Parts/AuthorHeader';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
-import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp';
+import { getHeaderPrefixForScreenReader, getMessageAriaLabel } from '~/utils';
import SearchContent from '~/components/Chat/Messages/Content/SearchContent';
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
+import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
import SubRow from '~/components/Chat/Messages/SubRow';
-import { fontSizeAtom } from '~/store/fontSize';
+import { useAttachments, useLocalize } from '~/hooks';
import { MessageContext } from '~/Providers';
import MultiMessage from './MultiMessage';
-import { useAttachments } from '~/hooks';
import Icon from './MessageIcon';
-import { cn } from '~/utils';
+
export default function Message(props: TMessageProps) {
- const fontSize = useAtomValue(fontSizeAtom);
+ const localize = useLocalize();
const {
message,
siblingIdx,
@@ -43,79 +42,27 @@ export default function Message(props: TMessageProps) {
isCreatedByUser = true,
} = message;
- let messageLabel = '';
- if (isCreatedByUser) {
- messageLabel = 'anonymous';
- } else {
- messageLabel = message.sender ?? '';
- }
+ /** Whoever opens a share link is not the author of the prompts in it, so this row
+ * keeps a neutral label. `com_user_message` reads "You", which is right in the chat
+ * view and wrong here: it is the screen-reader heading for the user turn, and it
+ * would credit every prompt the sharer wrote to the person reading the transcript. */
+ const messageLabel = isCreatedByUser ? localize('com_ui_user') : (message.sender ?? '');
return (
<>
-
{
).IntersectionObserver = originalIntersectionObserver;
});
- it('scrolls to the bottom when streaming content resizes and auto-scroll is active', () => {
+ it('rides the bottom when streaming content resizes and auto-scroll is active', () => {
renderScrolling();
const observer = MockResizeObserver.last();
expect(observer?.observe).toHaveBeenCalledWith(screen.getByTestId('content'));
+ const scrollable = screen.getByTestId('scrollable');
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+ scrollable.scrollTop = 700;
+
act(() => {
observer?.trigger();
});
- expect(mockScrollToBottom).toHaveBeenCalledTimes(1);
+ /** Written straight to the element rather than routed through the throttled
+ * scrollIntoView helper, so an answer arriving a few pixels at a time flows
+ * instead of lurching once every throttle window. */
+ expect(scrollable.scrollTop).toBe(800);
});
it('reconciles message layout after an explicit scroll to bottom', () => {
@@ -214,14 +225,26 @@ describe('useMessageScrolling resize reconciliation', () => {
expect(mockReconcileMessageContentLayout).toHaveBeenCalledWith(scrollable);
});
- it('does not follow resizes after the user aborts streaming auto-scroll', () => {
+ /**
+ * `useMessageProcess` raises the abort flag on any wheel at all, downward ones
+ * included, through a throttle whose trailing call lands after the gesture has
+ * ended. Gating on it meant scrolling down to the newest word could never resume
+ * the ride, while the scroll-to-bottom button, which touches no wheel, always
+ * could. Position and direction answer that question instead.
+ */
+ it('rides the bottom for a reader who is on it, even with the abort flag raised', () => {
renderScrolling({ contextOverrides: { abortScroll: true } });
+ const scrollable = screen.getByTestId('scrollable');
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+ scrollable.scrollTop = 700;
+
act(() => {
MockResizeObserver.last()?.trigger();
});
- expect(mockScrollToBottom).not.toHaveBeenCalled();
+ expect(scrollable.scrollTop).toBe(800);
});
it('does not follow resizes after the user scrolls away from the bottom', () => {
@@ -241,6 +264,63 @@ describe('useMessageScrolling resize reconciliation', () => {
expect(mockScrollToBottom).not.toHaveBeenCalled();
});
+ it('judges the first scroll against a real previous position, not against the top', () => {
+ const setAbortScroll = jest.fn();
+ renderScrolling({ contextOverrides: { setAbortScroll } });
+
+ const scrollable = screen.getByTestId('scrollable');
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+
+ /** A thread opens at its end, so the reader's first gesture carries a large
+ * positive scrollTop. Measured from 0 it read as a jump down onto the end, and
+ * the reader was re-pinned to the stream they were trying to leave. */
+ setAbortScroll.mockClear();
+ scrollable.scrollTop = 700;
+ fireEvent.scroll(scrollable);
+
+ expect(setAbortScroll).not.toHaveBeenCalled();
+
+ /** With a baseline taken, the same gesture is judged on its real delta. */
+ scrollable.scrollTop = 500;
+ fireEvent.scroll(scrollable);
+
+ act(() => {
+ MockResizeObserver.last()?.trigger();
+ });
+
+ expect(scrollable.scrollTop).toBe(500);
+ });
+
+ it('obeys the first scroll away from a thread that was placed at its end', () => {
+ renderScrolling();
+
+ const scrollable = screen.getByTestId('scrollable');
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+
+ /** The thread opens at its end without the reader touching it, so the position
+ * it was placed at is what their first gesture has to be judged against. */
+ scrollable.scrollTop = 800;
+ act(() => {
+ mockScrollCallback?.();
+ });
+
+ /** One PageUp, and it is the first event the handler sees. A key press buys a
+ * single resize of grace and clears no flag of its own, so if the gesture is
+ * spent taking a baseline the reader is ridden straight back to the end. */
+ fireEvent.keyDown(screen.getByTestId('content'), { key: 'PageUp' });
+ scrollable.scrollTop = 300;
+ fireEvent.scroll(scrollable);
+
+ act(() => {
+ MockResizeObserver.last()?.trigger();
+ MockResizeObserver.last()?.trigger();
+ });
+
+ expect(scrollable.scrollTop).toBe(300);
+ });
+
it('does not follow the next resize after user interaction inside message content', () => {
renderScrolling();
@@ -253,6 +333,38 @@ describe('useMessageScrolling resize reconciliation', () => {
expect(mockScrollToBottom).not.toHaveBeenCalled();
});
+ /**
+ * One interaction rarely settles in a single frame: expanding a tool result renders
+ * the container, then its contents arrive and grow it again. Only the first resize
+ * was credited to the interaction, so the second read the reader as still riding the
+ * stream and put them back on the bottom they had just deliberately left.
+ */
+ it('keeps an interaction that settles over several resizes from re-pinning the reader', () => {
+ renderScrolling();
+
+ const scrollable = screen.getByTestId('scrollable');
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+ scrollable.scrollTop = 800;
+
+ fireEvent.pointerDown(screen.getByTestId('content'));
+
+ /** The expansion renders, which is the resize the interaction is credited with. */
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 2000, configurable: true });
+ act(() => {
+ MockResizeObserver.last()?.trigger();
+ });
+ expect(scrollable.scrollTop).toBe(800);
+
+ /** Its contents then load. This belongs to the same interaction, not to the stream. */
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 2600, configurable: true });
+ act(() => {
+ MockResizeObserver.last()?.trigger();
+ });
+
+ expect(scrollable.scrollTop).toBe(800);
+ });
+
it('clamps the scroll position back to content after a resize shrink', () => {
renderScrolling({ contextOverrides: { abortScroll: true } });
@@ -269,14 +381,202 @@ describe('useMessageScrolling resize reconciliation', () => {
expect(mockScrollToBottom).not.toHaveBeenCalled();
});
+ /**
+ * Sending arms a smooth glide down to the newest word, and the landing re-pins the
+ * thread to the bottom. The landing is scheduled for the whole glide window, so a
+ * reader who changes their mind and heads up mid-flight was pinned again anyway and
+ * dragged back on the next streaming resize.
+ */
+ it('lets an upward gesture during the send glide beat the pending landing', () => {
+ jest.useFakeTimers();
+ try {
+ const view = render(
+
+
+
+
+ ,
+ );
+
+ const scrollable = screen.getByTestId('scrollable');
+ const scrollTo = jest.fn();
+ (scrollable as unknown as { scrollTo: jest.Mock }).scrollTo = scrollTo;
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+ scrollable.scrollTop = 0;
+
+ view.rerender(
+
+
+
+
+ ,
+ );
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: 800, behavior: 'smooth' });
+
+ /** The reader heads up while the glide is still in flight. */
+ scrollable.scrollTop = 400;
+ fireEvent.wheel(scrollable, { deltaY: -120 });
+
+ act(() => {
+ jest.advanceTimersByTime(glideWindow);
+ });
+
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1200, configurable: true });
+ act(() => {
+ MockResizeObserver.last()?.trigger();
+ });
+
+ expect(scrollable.scrollTop).toBe(400);
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ /**
+ * A reader who scrolls away during one answer leaves the abort flag raised, and
+ * nothing lowers it until the next connection opens, which is after the send has
+ * already been seen. Spending the start of the turn on that first pass left the
+ * answer the reader had just asked for streaming offscreen.
+ */
+ it('starts the turn once a stale abort flag clears', () => {
+ const view = render(
+
+
+
+
+ ,
+ );
+
+ const scrollable = screen.getByTestId('scrollable');
+ const scrollTo = jest.fn();
+ (scrollable as unknown as { scrollTo: jest.Mock }).scrollTo = scrollTo;
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+ scrollable.scrollTop = 0;
+
+ /** They left the bottom during the previous answer, which is what raised it. */
+ fireEvent.wheel(scrollable, { deltaY: -120 });
+
+ view.rerender(
+
+
+
+
+ ,
+ );
+
+ expect(scrollTo).not.toHaveBeenCalled();
+
+ /** The connection opens and lowers the flag. */
+ view.rerender(
+
+
+
+
+ ,
+ );
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: 800, behavior: 'smooth' });
+ });
+
+ it('leaves the send glide alone while the answer streams in', () => {
+ const view = render(
+
+
+
+
+ ,
+ );
+
+ const scrollable = screen.getByTestId('scrollable');
+ const scrollTo = jest.fn();
+ (scrollable as unknown as { scrollTo: jest.Mock }).scrollTo = scrollTo;
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+ scrollable.scrollTop = 0;
+
+ view.rerender(
+
+
+
+
+ ,
+ );
+
+ expect(scrollTo).toHaveBeenCalledWith({ top: 800, behavior: 'smooth' });
+
+ /** The next delta of the answer arrives while the glide is still travelling. */
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1100, configurable: true });
+ view.rerender(
+
+
+
+
+ ,
+ );
+
+ /** A plain follow would have written scrollTop outright and killed the animation. */
+ expect(scrollable.scrollTop).toBe(0);
+ });
+
+ /**
+ * Following stands down for the length of the glide, so an answer that arrives
+ * while it travels moves the bottom past the target the glide aimed at. Landing
+ * has to close that gap, or a short response settles short of its own end.
+ */
+ it('catches up to the new bottom when the glide lands', () => {
+ const view = render(
+
+
+
+
+ ,
+ );
+
+ const scrollable = screen.getByTestId('scrollable');
+ (scrollable as unknown as { scrollTo: jest.Mock }).scrollTo = jest.fn();
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
+ Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+ scrollable.scrollTop = 0;
+
+ view.rerender(
+
+
+
+
+ ,
+ );
+
+ /** The whole answer arrives before the glide reports that it landed. */
+ Object.defineProperty(scrollable, 'scrollHeight', { value: 1400, configurable: true });
+ act(() => {
+ fireEvent(scrollable, new Event('scrollend'));
+ });
+
+ expect(scrollable.scrollTop).toBe(1200);
+ });
+
it('does not clamp to rendered content bottom during general resize reconciliation', () => {
- renderScrolling({ contextOverrides: { abortScroll: true } });
+ renderScrolling();
const scrollable = screen.getByTestId('scrollable');
const content = screen.getByTestId('content');
Object.defineProperty(scrollable, 'scrollHeight', { value: 1000, configurable: true });
Object.defineProperty(scrollable, 'clientHeight', { value: 200, configurable: true });
+
+ /** Move away from the end so the reader is left alone, which is the state this
+ * is about: reconciliation must not drag them to the rendered content bottom. */
+ scrollable.scrollTop = 900;
+ fireEvent.scroll(scrollable);
scrollable.scrollTop = 700;
+ fireEvent.scroll(scrollable);
+
setRect(scrollable, { top: 0, bottom: 200, height: 200 });
setRect(content, { top: -700, bottom: -200, height: 500 });
diff --git a/client/src/hooks/Messages/useCopyToClipboard.spec.ts b/client/src/hooks/Messages/useCopyToClipboard.spec.ts
index 6e0844100a..b5059a4fd1 100644
--- a/client/src/hooks/Messages/useCopyToClipboard.spec.ts
+++ b/client/src/hooks/Messages/useCopyToClipboard.spec.ts
@@ -1,6 +1,6 @@
-import { renderHook, act } from '@testing-library/react';
import copy from 'copy-to-clipboard';
import { ContentTypes } from 'librechat-data-provider';
+import { renderHook, act } from '@testing-library/react';
import type {
SearchResultData,
ProcessedOrganic,
@@ -64,6 +64,35 @@ describe('useCopyToClipboard', () => {
});
});
+ it('copies errors and tool input and output with surrounding text', () => {
+ const content = [
+ { type: ContentTypes.TEXT, text: 'I checked the deployment.' },
+ {
+ type: ContentTypes.TOOL_CALL,
+ tool_call: {
+ type: 'tool_call',
+ name: 'get_deployment',
+ args: '{"service":"web"}',
+ output: '{"status":"failed"}',
+ },
+ },
+ { type: ContentTypes.ERROR, error: 'Deployment lookup failed' },
+ ] as TMessageContentParts[];
+
+ const { result } = renderHook(() => useCopyToClipboard({ content }));
+
+ act(() => {
+ result.current(mockSetIsCopied);
+ });
+
+ const copiedText = mockCopy.mock.calls[0]?.[0];
+ expect(copiedText).toContain('I checked the deployment.');
+ expect(copiedText).toContain('get_deployment');
+ expect(copiedText).toContain('service');
+ expect(copiedText).toContain('status');
+ expect(copiedText).toContain('Deployment lookup failed');
+ });
+
it('should reset isCopied after timeout', () => {
const { result } = renderHook(() =>
useCopyToClipboard({
diff --git a/client/src/hooks/Messages/useCopyToClipboard.ts b/client/src/hooks/Messages/useCopyToClipboard.ts
index f827d95200..c0cd2aebbd 100644
--- a/client/src/hooks/Messages/useCopyToClipboard.ts
+++ b/client/src/hooks/Messages/useCopyToClipboard.ts
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef } from 'react';
import copy from 'copy-to-clipboard';
-import { ContentTypes, SearchResultData } from 'librechat-data-provider';
+import { SearchResultData } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
+import type { LocalizeFunction } from '~/common';
import {
SPAN_REGEX,
CLEANUP_REGEX,
@@ -9,6 +10,8 @@ import {
STANDALONE_PATTERN,
INVALID_CITATION_REGEX,
} from '~/utils/citations';
+import { formatMessageContent } from '~/hooks/Conversations/format';
+import useLocalize from '~/hooks/useLocalize';
type Source = {
link: string;
@@ -27,6 +30,35 @@ const refTypeMap: Record = {
video: 'videos',
};
+export function serializeMessageForClipboard({
+ text,
+ content,
+ localize,
+}: Partial> & { localize: LocalizeFunction }): string {
+ if (!Array.isArray(content) || content.length === 0) {
+ return text ?? '';
+ }
+
+ return content
+ .filter((part) => part != null)
+ .map((part) => {
+ const formatted = formatMessageContent({
+ sender: '',
+ content: part,
+ format: 'text',
+ localize,
+ });
+ if (formatted.length === 0) {
+ return '';
+ }
+
+ const [label, value] = formatted;
+ return label ? `${label}:\n${value}` : value;
+ })
+ .filter((value) => value.trim().length > 0)
+ .join('\n');
+}
+
export default function useCopyToClipboard({
text,
content,
@@ -34,6 +66,7 @@ export default function useCopyToClipboard({
}: Partial> & {
searchResults?: { [key: string]: SearchResultData };
}) {
+ const localize = useLocalize();
const copyTimeoutRef = useRef(null);
useEffect(() => {
@@ -51,17 +84,7 @@ export default function useCopyToClipboard({
}
setIsCopied(true);
- // Get the message text from content or text
- let messageText = text ?? '';
- if (content) {
- messageText = content.reduce((acc, curr, i) => {
- if (curr.type === ContentTypes.TEXT) {
- const text = typeof curr.text === 'string' ? curr.text : (curr.text?.value ?? '');
- return acc + text + (i === content.length - 1 ? '' : '\n');
- }
- return acc;
- }, '');
- }
+ const messageText = serializeMessageForClipboard({ text, content, localize });
// Early return if no search data
if (!searchResults || Object.keys(searchResults).length === 0) {
@@ -100,7 +123,7 @@ export default function useCopyToClipboard({
setIsCopied(false);
}, 3000);
},
- [text, content, searchResults],
+ [text, content, searchResults, localize],
);
return copyToClipboard;
diff --git a/client/src/hooks/Messages/useMessageScrolling.ts b/client/src/hooks/Messages/useMessageScrolling.ts
index b15b4aff51..03cce40878 100644
--- a/client/src/hooks/Messages/useMessageScrolling.ts
+++ b/client/src/hooks/Messages/useMessageScrolling.ts
@@ -9,6 +9,22 @@ import store from '~/store';
const resizeFollowThreshold = 120;
+/** How long a glide is given to land before per-frame following resumes. */
+const glideTimeout = 700;
+
+/** Arriving counts from further out than leaving does, because while an answer
+ * streams the end is a moving target: it recedes between the reader's last
+ * wheel tick and the frame that measures it, so someone scrolling all the way
+ * down still lands tens of pixels short. Judging arrival as tightly as
+ * departure means they can never quite catch it. */
+const attachThreshold = 150;
+const detachThreshold = 24;
+
+const prefersReducedMotion = () =>
+ typeof window !== 'undefined' &&
+ typeof window.matchMedia === 'function' &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
const autoScroll = useRecoilValue(store.autoScroll);
@@ -16,6 +32,21 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
const contentRef = useRef(null);
const messagesEndRef = useRef(null);
const isNearBottomRef = useRef(true);
+ /** The single authority for whether the thread is riding the stream. Driven only
+ * by what the reader does, never by the observer, whose zero-height sentinel
+ * flickers as content grows and was the source of the attach/detach churn. */
+ const isStuckRef = useRef(true);
+ const isGlidingRef = useRef(false);
+ /** Raised wherever a reader gesture lets go of the bottom, so a glide already in
+ * flight knows not to re-pin them when it lands. */
+ const glideInterruptedRef = useRef(false);
+ const glideTimerRef = useRef | null>(null);
+ /** Seeded below zero rather than at 0 so the first event after mount is read as
+ * "no previous sample" instead of as a jump down from the top. Every
+ * programmatic move writes the position it left the thread at, so this stands
+ * only until something has actually placed the thread. */
+ const lastScrollTopRef = useRef(-1);
+ const wasSubmittingRef = useRef(false);
const suppressNextResizeFollowRef = useRef(false);
const { conversation, conversationId } = useMessagesConversation();
const { setAbortScroll, isSubmitting, abortScroll } = useMessagesSubmission();
@@ -29,20 +60,95 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
return distance <= resizeFollowThreshold;
}, []);
- /** The scroll-to-bottom button owns the IntersectionObserver (so its
- * visibility state never re-renders the message tree host) and reports
- * intersection back through this callback. */
+ /** The scroll-to-bottom button owns the IntersectionObserver (so its visibility
+ * state never re-renders the message tree host) and reports intersection back
+ * through this callback.
+ *
+ * It reports only. The sentinel it watches has no height and is observed at a
+ * 0.85 threshold, a ratio a zero-area box cannot reach, so it flickers while an
+ * answer streams. Letting it decide whether to ride the stream is what made the
+ * thread attach and detach on its own.
+ */
const handleNearBottomChange = useCallback((isNearBottom: boolean) => {
isNearBottomRef.current = isNearBottom;
}, []);
+ const distanceFromEnd = useCallback(() => {
+ const scrollEl = scrollableRef.current;
+ if (!scrollEl) {
+ return 0;
+ }
+ return scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight;
+ }, []);
+
+ /** Direction is judged against the last sample, so anything that moves the thread
+ * on the reader's behalf has to leave one behind. A thread opening at its end, or
+ * ridden down by the stream, is placed without the reader touching it; with no
+ * record of where it was put, their first gesture is spent taking the baseline
+ * instead of being obeyed, and a keyboard scroll away from the end is lost. */
+ const rememberScrollPosition = useCallback(() => {
+ const scrollEl = scrollableRef.current;
+ if (!scrollEl) {
+ return;
+ }
+ lastScrollTopRef.current = scrollEl.scrollTop;
+ }, []);
+
const debouncedHandleScroll = useCallback(() => {
+ const scrollEl = scrollableRef.current;
isNearBottomRef.current = getIsNearBottom();
- }, [getIsNearBottom]);
+ if (!scrollEl) {
+ return;
+ }
+
+ /** Direction comes from where the thread actually moved, which covers the
+ * wheel, a trackpad and a dragged scrollbar alike. */
+ const top = scrollEl.scrollTop;
+ const previousTop = lastScrollTopRef.current;
+ lastScrollTopRef.current = top;
+
+ /** A thread opens already scrolled to its end, so the first event carries a
+ * large positive `scrollTop` with nothing to compare it against. Measuring it
+ * from 0 calls it downward, and the gesture that produced it, a reader pushing
+ * up and away from the stream, is swallowed: a single PageUp reads as an
+ * arrival and leaves the thread riding the answer. Take this one as the
+ * baseline and judge direction from the next. */
+ if (previousTop < 0) {
+ return;
+ }
+
+ const movingDown = top >= previousTop;
+ const distance = distanceFromEnd();
+
+ /** Arriving is judged here rather than on the wheel tick that started it: the
+ * browser animates wheel scrolling, so at tick time the thread is still far
+ * short of where that tick is taking it, and reading the distance then calls
+ * a gesture that lands on the end a miss. */
+ if (movingDown) {
+ if (distance <= attachThreshold) {
+ isStuckRef.current = true;
+ /** Cleared unconditionally rather than on a read of the current value.
+ * `Message` raises this on every wheel tick, downward ones included, so
+ * it is set again on the way here; and the value this closure can see is
+ * a render behind, so a conditional clear loses the race and leaves the
+ * ride vetoed for the rest of the answer. Recoil no-ops an unchanged
+ * write, so repeating it costs nothing. */
+ setAbortScroll(false);
+ }
+ return;
+ }
+
+ if (distance > detachThreshold) {
+ isStuckRef.current = false;
+ glideInterruptedRef.current = true;
+ }
+ }, [distanceFromEnd, getIsNearBottom, setAbortScroll]);
const scrollCallback = () => {
reconcileMessageContentLayout(scrollableRef.current);
isNearBottomRef.current = true;
+ isStuckRef.current = true;
+ rememberScrollPosition();
};
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
@@ -54,6 +160,74 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
},
});
+ /**
+ * Ride the bottom of the thread.
+ *
+ * Written straight to `scrollTop` rather than routed through the throttled
+ * `scrollIntoView` helper: an answer arrives a few pixels at a time, so
+ * correcting on every frame reads as the text simply flowing upward, while
+ * correcting every 145ms reads as a thread that lurches.
+ *
+ * The glide is for distance only, when a send has to travel from wherever the
+ * reader was down to the newest word. Following while one is in flight would
+ * cancel it on the first frame.
+ */
+ const followBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
+ const scrollEl = scrollableRef.current;
+ if (!scrollEl) {
+ return;
+ }
+ const target = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
+ if (Math.abs(scrollEl.scrollTop - target) < 1) {
+ return;
+ }
+
+ if (behavior !== 'smooth' || prefersReducedMotion()) {
+ scrollEl.scrollTop = target;
+ /** Riding the bottom re-affirms that we are on it. The throttled helper this
+ * replaced did the same through its callback, and without it a single
+ * under-reported intersection ends the ride for the rest of the turn. */
+ isNearBottomRef.current = true;
+ isStuckRef.current = true;
+ lastScrollTopRef.current = target;
+ return;
+ }
+
+ isGlidingRef.current = true;
+ glideInterruptedRef.current = false;
+ if (glideTimerRef.current != null) {
+ clearTimeout(glideTimerRef.current);
+ }
+ const land = () => {
+ isGlidingRef.current = false;
+ scrollEl.removeEventListener('scrollend', land);
+ if (glideTimerRef.current != null) {
+ clearTimeout(glideTimerRef.current);
+ glideTimerRef.current = null;
+ }
+ /** Scrolling up during the glide is the reader taking over. Re-pinning them
+ * here would hand the thread straight back to the stream on the next resize,
+ * and the timeout fires for the whole glide window even once the animation
+ * has visibly settled, so the gesture has to win outright. */
+ if (glideInterruptedRef.current) {
+ return;
+ }
+ isNearBottomRef.current = true;
+ isStuckRef.current = true;
+ /** Following stands down for the whole trip, so anything that streamed in
+ * meanwhile moved the bottom past the target this glide aimed at. Close that
+ * gap on arrival, or a short answer settles a few lines short of its end. */
+ const settled = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
+ if (Math.abs(scrollEl.scrollTop - settled) >= 1) {
+ scrollEl.scrollTop = settled;
+ }
+ lastScrollTopRef.current = scrollEl.scrollTop;
+ };
+ scrollEl.addEventListener('scrollend', land, { once: true });
+ glideTimerRef.current = setTimeout(land, glideTimeout);
+ scrollEl.scrollTo({ top: target, behavior: 'smooth' });
+ }, []);
+
const clampScrollToContent = useCallback(() => {
const scrollEl = scrollableRef.current;
if (!scrollEl) {
@@ -79,14 +253,35 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
if (suppressNextResizeFollowRef.current) {
suppressNextResizeFollowRef.current = false;
isNearBottomRef.current = getIsNearBottom();
+ /** An interaction rarely settles in one frame: a tool result expands, then its
+ * contents arrive and grow it again. Only the first resize is credited to the
+ * gesture, so letting go of the ride has to happen here. Leaving it to the
+ * resize that follows means reading a reader who has just been pushed far up
+ * their own thread as still riding the stream, and handing them back to the
+ * bottom they deliberately left. Where the interaction actually left them
+ * decides it, so one that kept them on the end keeps streaming. */
+ isStuckRef.current = isNearBottomRef.current;
return;
}
- if (shouldFollowResize && isSubmitting && abortScroll !== true && isNearBottomRef.current) {
- scrollToBottom?.();
+ /** A glide already on its way to the bottom is heading exactly where this
+ * would put us, and touching the position would cancel it. */
+ if (isGlidingRef.current) {
+ return;
+ }
+
+ /** Deliberately not gated on `abortScroll`. `useMessageProcess` raises that on
+ * any wheel at all, downward ones included, through a 500ms throttle whose
+ * trailing call lands after the gesture has ended: no clear timed to the
+ * gesture can outlive it, which is why scrolling down to the newest word
+ * could never resume the ride while the button, which touches no wheel,
+ * always could. Whether the reader is riding the stream is answered here by
+ * where they actually are and which way they were going. */
+ if (shouldFollowResize && isSubmitting && isStuckRef.current) {
+ followBottom();
}
},
- [abortScroll, clampScrollToContent, getIsNearBottom, isSubmitting, scrollToBottom],
+ [clampScrollToContent, followBottom, getIsNearBottom, isSubmitting],
);
useEffect(() => {
@@ -118,6 +313,59 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
};
}, []);
+ useEffect(() => {
+ const scrollEl = scrollableRef.current;
+ if (!scrollEl || typeof window === 'undefined') {
+ return;
+ }
+
+ /** Direction decides, not position. Heading up lets go at once and stays let
+ * go, however close to the end the reader still is; anything position-based
+ * drags them back before they have cleared the band, which reads as the
+ * thread refusing to be scrolled.
+ *
+ * Heading down only re-attaches on arrival at the end.
+ *
+ * `Message` aborts auto-scroll on `wheel` alone, and a tick against the end
+ * moves nothing and so fires no `scroll`, leaving that flag set with nothing
+ * to clear it. Clearing it on arrival is what lets the ride resume; the
+ * button escaped the problem only by never touching the wheel. */
+ /** An upward tick releases immediately, without waiting to see where it lands.
+ * Re-attaching is left entirely to arrival, handled on scroll.
+ *
+ * A downward tick clears the abort flag outright. `Message` raises it on any
+ * wheel at all, and a tick against the end moves nothing, so it fires no
+ * `scroll` for the arrival handler to answer: the last tick of scrolling down
+ * to the newest word leaves the ride vetoed with nothing left to lift it.
+ * Scrolling down is never an intent to abandon the stream. */
+ const onWheel = (event: WheelEvent) => {
+ if (event.deltaY < 0) {
+ isStuckRef.current = false;
+ glideInterruptedRef.current = true;
+ return;
+ }
+ /** Next frame, not now: React binds `Message`'s handler at the root, above
+ * this container, so it runs after this one and would put the flag straight
+ * back. Clearing once the event has finished dispatching is what makes it
+ * stick. */
+ window.requestAnimationFrame(() => setAbortScroll(false));
+ };
+
+ scrollEl.addEventListener('wheel', onWheel, { passive: true });
+ return () => {
+ scrollEl.removeEventListener('wheel', onWheel);
+ };
+ }, [setAbortScroll]);
+
+ useEffect(
+ () => () => {
+ if (glideTimerRef.current != null) {
+ clearTimeout(glideTimerRef.current);
+ }
+ },
+ [],
+ );
+
useEffect(() => {
if (!messagesTree || messagesTree.length === 0) {
return;
@@ -127,8 +375,33 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
return;
}
- if (isSubmitting && scrollToBottom && abortScroll !== true) {
- scrollToBottom();
+ const startedSubmitting = isSubmitting && !wasSubmittingRef.current;
+
+ if (!isSubmitting) {
+ wasSubmittingRef.current = false;
+ }
+
+ /** The start of a turn is spent only once it can be acted on. A reader who
+ * scrolled away during the last answer leaves the abort flag raised, and
+ * nothing lowers it until the next connection opens, which is after this effect
+ * has already seen the send. Marking the turn as started on that first pass
+ * spent it against a closed gate: by the time the flag cleared there was no
+ * start left to honour and the reader was still detached, so the answer they
+ * had just asked for streamed on offscreen. */
+ if (isSubmitting && abortScroll !== true) {
+ wasSubmittingRef.current = true;
+ /** Sending re-attaches: the reader asked for this answer, so take them to it.
+ * The one long trip of a turn, and the only one worth animating. */
+ if (startedSubmitting) {
+ isNearBottomRef.current = true;
+ isStuckRef.current = true;
+ followBottom('smooth');
+ } else if (isStuckRef.current && !isGlidingRef.current) {
+ /** Every delta of the answer reruns this effect, and a plain follow writes
+ * scrollTop outright, which cancels an animation on its first frame. The
+ * glide is left to finish the trip it started. */
+ followBottom();
+ }
}
return () => {
@@ -136,7 +409,7 @@ export default function useMessageScrolling(messagesTree?: TMessage[] | null) {
scrollToBottom && scrollToBottom.cancel();
}
};
- }, [isSubmitting, messagesTree, scrollToBottom, abortScroll]);
+ }, [isSubmitting, messagesTree, scrollToBottom, abortScroll, followBottom]);
useEffect(() => {
if (!messagesEndRef.current || !scrollableRef.current) {
diff --git a/client/src/hooks/useGenerationsByLatest.ts b/client/src/hooks/useGenerationsByLatest.ts
index ddedc3ec15..88c02511f7 100644
--- a/client/src/hooks/useGenerationsByLatest.ts
+++ b/client/src/hooks/useGenerationsByLatest.ts
@@ -40,6 +40,7 @@ export default function useGenerationsByLatest({
finish_reason &&
finish_reason !== 'stop' &&
!isEditing &&
+ !isSubmitting &&
!searchResult &&
isEditableEndpoint;
@@ -58,8 +59,11 @@ export default function useGenerationsByLatest({
const regenerateEnabled =
!isCreatedByUser && !searchResult && !isEditing && !isSubmitting && branchingSupported;
+ const isActiveStreamingMessage =
+ isSubmitting && (latestMessageId == null || messageId === latestMessageId);
+
const hideEditButton =
- isSubmitting ||
+ isActiveStreamingMessage ||
error ||
searchResult ||
!branchingSupported ||
@@ -71,6 +75,7 @@ export default function useGenerationsByLatest({
forkingSupported,
continueSupported,
regenerateEnabled,
+ isActiveStreamingMessage,
isEditableEndpoint,
hideEditButton,
};
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 0a47014d49..e816dd43b9 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -1536,6 +1536,7 @@
"com_ui_message_nav_go_to_user": "Go to user message: {{0}}",
"com_ui_message_nav_next": "Navigate to next message",
"com_ui_message_nav_previous": "Navigate to previous message",
+ "com_ui_message_part_empty": "Message content cannot be empty.",
"com_ui_method": "Method",
"com_ui_microphone_unavailable": "Microphone is not available",
"com_ui_min_tags": "Cannot remove more values, a minimum of {{0}} are required.",
@@ -1788,9 +1789,10 @@
"com_ui_sandbox_starting": "Starting sandbox environment",
"com_ui_save": "Save",
"com_ui_save_badge_changes": "Save badge changes?",
+ "com_ui_save_before_rerun": "Rerunning applies one edited section at a time. Save to keep all of these changes.",
"com_ui_save_key_error": "Failed to save API key. Please try again.",
"com_ui_save_key_success": "API key saved successfully",
- "com_ui_save_submit": "Save & Submit",
+ "com_ui_save_message_error": "The message could not be saved. Your changes are still in the editor.",
"com_ui_saved": "Saved!",
"com_ui_saving": "Saving...",
"com_ui_schema": "Schema",
@@ -2139,8 +2141,10 @@
"com_ui_unpin_error": "Failed to unpin conversation",
"com_ui_unset": "Unset",
"com_ui_untitled": "Untitled",
+ "com_ui_unsaved_changes": "Unsaved changes",
"com_ui_update": "Update",
"com_ui_update_mcp_server": "Update MCP server",
+ "com_ui_update_rerun": "Update & rerun",
"com_ui_update_shared_link": "Update link",
"com_ui_update_shared_link_confirm_description": "This publishes the latest messages and your current file-sharing choice to the existing link. The URL stays the same, and anyone with access can see the updated snapshot.",
"com_ui_update_shared_link_confirm_title": "Update shared link?",
diff --git a/client/src/style.css b/client/src/style.css
index aa0fdb90a3..bb45a39c03 100644
--- a/client/src/style.css
+++ b/client/src/style.css
@@ -2098,9 +2098,10 @@ html {
transform-origin: 50% 50%;
}
-.message-content {
+.message-content,
+.message-editor-text {
font-size: var(--markdown-font-size, var(--font-size-base));
- line-height: 1.4;
+ line-height: 1.6;
}
.message-content pre code {
diff --git a/e2e/setup/fake-model.js b/e2e/setup/fake-model.js
index 2e759e91fc..30528fc540 100644
--- a/e2e/setup/fake-model.js
+++ b/e2e/setup/fake-model.js
@@ -25,6 +25,7 @@ const ASSERT_PROVIDER_FILE_MARKER = 'E2E_ASSERT_PROVIDER_FILE:';
const ASSERT_AGENT_CONTEXT_MARKER = 'E2E_ASSERT_AGENT_CONTEXT:';
const ASSERT_QUOTE_MARKER = 'E2E_ASSERT_QUOTE:';
const REPLY_MARKER = 'E2E_REPLY:';
+const THINK_REPLY_MARKER = 'E2E_THINK_REPLY:';
const COUNTED_REPLY_MARKER = 'E2E_COUNTED_REPLY:';
const ORDERED_REPLY_MARKER = 'E2E_ORDERED_REPLY:';
const SLOW_REPLY_MARKER = 'E2E_SLOW_REPLY:';
@@ -488,6 +489,15 @@ function replyResponses(text) {
};
}
+ const thinkName = getMarkerValue(text, THINK_REPLY_MARKER);
+ if (thinkName) {
+ /** The `` tags are parsed downstream by the agents stream pipeline, so this
+ * yields a reasoning part followed by a text part: two separately editable parts. */
+ return {
+ responses: [`E2E reasoning ${thinkName}\n\nE2E reply ${thinkName}`],
+ };
+ }
+
const countedName = getMarkerValue(text, COUNTED_REPLY_MARKER);
if (countedName) {
const count = (countedReplies.get(countedName) ?? 0) + 1;
diff --git a/e2e/specs/messages.spec.ts b/e2e/specs/messages.spec.ts
index 91131701c8..f986d1f0e4 100644
--- a/e2e/specs/messages.spec.ts
+++ b/e2e/specs/messages.spec.ts
@@ -100,7 +100,7 @@ test.describe('Messaging suite', () => {
await page.getByRole('button', { name: 'edit' }).click();
const editResponsePromise = [
page.waitForResponse(waitForServerStream),
- await page.getByRole('button', { name: 'Save & Submit' }).click(),
+ await page.getByRole('button', { name: 'Update & rerun' }).click(),
];
const [editResponse] = (await Promise.all(editResponsePromise)) as [Response];
diff --git a/e2e/specs/mock/helpers.ts b/e2e/specs/mock/helpers.ts
index c4fba0bc2d..fa08d43bc3 100644
--- a/e2e/specs/mock/helpers.ts
+++ b/e2e/specs/mock/helpers.ts
@@ -97,6 +97,10 @@ export const messagesView = (page: Page) => page.getByTestId('messages-view');
export const replyPrompt = (label: string) => `E2E_REPLY:${label}`;
export const replyText = (label: string) => `E2E reply ${label}`;
+/** Same, for a reply that streams a reasoning part ahead of its text part. */
+export const thinkPrompt = (label: string) => `E2E_THINK_REPLY:${label}`;
+export const thinkText = (label: string) => `E2E reasoning ${label}`;
+
/** The mock reply as rendered in the conversation, scoped to the messages view. */
export function mockReply(page: Page) {
return messagesView(page).getByText(new RegExp(MOCK_REPLY_TEXT, 'i'));
diff --git a/e2e/specs/mock/hover-actions.spec.ts b/e2e/specs/mock/hover-actions.spec.ts
index a55e7915ae..4a53bd51d8 100644
--- a/e2e/specs/mock/hover-actions.spec.ts
+++ b/e2e/specs/mock/hover-actions.spec.ts
@@ -6,16 +6,18 @@ import {
messagesView,
selectMockEndpoint,
sendMessage,
+ sendMessageAndWaitForCompletion,
} from './helpers';
/**
- * Regression guard for the edit action leaking through mid-stream.
+ * Regression guard for the actions offered on a half-written response.
*
- * The unit spec can only assert class names: jsdom applies no stylesheet, so it
- * cannot see that the shared Button's `disabled:opacity-50` (specificity 0,2,0)
- * outranks a plain `opacity-0` (0,1,0) and repaints the hidden pencil at half
- * opacity. Only a real browser resolves that cascade, which is why this lives
- * here rather than in Jest.
+ * Edit and fork cannot act on a message that is still streaming, so the toolbar
+ * omits them outright rather than rendering them disabled: the shared Button's
+ * `disabled:opacity-50` (specificity 0,2,0) outranks a plain `opacity-0` (0,1,0)
+ * and would repaint a dimmed ghost of the hidden action. Asserting absence is
+ * what makes that ghost unrepresentable, and jsdom resolves no stylesheet, so
+ * the guard lives here rather than in Jest.
*/
const uniqueLabel = (prefix: string) =>
@@ -27,10 +29,16 @@ const userTurn = (page: Page) =>
.filter({ has: page.locator('.user-turn') })
.last();
+const assistantTurn = (page: Page) =>
+ messagesView(page)
+ .locator('.message-render')
+ .filter({ has: page.locator('.agent-turn') })
+ .last();
+
const stopButton = (page: Page) => page.getByRole('button', { name: 'Stop generating' });
test.describe('message hover actions', () => {
- test('keeps the edit action fully hidden while a generation streams', async ({ page }) => {
+ test('withholds inapplicable actions while a generation streams', async ({ page }) => {
test.setTimeout(120000);
const label = uniqueLabel('hover-edit');
@@ -41,26 +49,137 @@ test.describe('message hover actions', () => {
expect(run.ok()).toBeTruthy();
await expect(messagesView(page).getByText('chunk-010')).toBeVisible({ timeout: 15000 });
- const row = userTurn(page);
- const editButton = row.locator('button[id^="edit-"]');
- const copyButton = row.getByRole('button', { name: 'Copy to clipboard' });
+ const streaming = assistantTurn(page);
+ const streamingEdit = streaming.locator('button[id^="edit-"]');
+ const streamingFork = streaming.getByRole('button', { name: 'Open Fork Menu' });
- await row.hover();
-
- /** Pin the window: if the stream already settled, the edit assertion below
+ /** Pin the window: if the stream already settled, every assertion below
* would be checking the wrong state and pass for the wrong reason. */
await expect(stopButton(page)).toBeVisible();
- /** The sibling action proves the row is genuinely hovered — without it a
- * broken hover would make the edit assertion pass for the wrong reason. */
- await expect(copyButton).toHaveCSS('opacity', '1');
- await expect(editButton).toHaveCSS('opacity', '0');
- await expect(editButton).toBeDisabled();
+ /** Copying half a sentence is never what the reader wants, so the response
+ * offers nothing at all until it settles. */
+ const streamingCopy = streaming.getByRole('button', { name: 'Copy to clipboard' });
+ await expect(streamingCopy).toHaveCount(0);
+ await expect(streamingEdit).toHaveCount(0);
+ await expect(streamingFork).toHaveCount(0);
- /** ...and the affordance must come back, or "hidden" would just be "gone". */
+ /** The settled turn above carries the positive control: the toolbar system is
+ * mounted and working, so the absences above read as "withheld" rather than
+ * "nothing rendered yet". */
+ await expect(userTurn(page).locator('button[id^="edit-"]')).toBeEnabled();
+
+ /** ...and the response earns them back, or "withheld" would just be "gone". */
await expect(stopButton(page)).toBeHidden({ timeout: 60000 });
+ await expect(streamingCopy).toBeEnabled();
+ await expect(streamingEdit).toBeEnabled();
+ await expect(streamingFork).toBeEnabled();
+ });
+
+ /**
+ * A trigger whose surface is open must survive the pointer leaving the row,
+ * or the editor and the fork popover end up anchored to an invisible button.
+ *
+ * Both assertions deliberately move focus out of the row first. `.message-render`
+ * carries the `group`, so an editor focused inside it satisfies
+ * `group-focus-within:opacity-100` on its own: asserting while the textarea still
+ * holds focus passes whether or not the active state is honoured.
+ */
+ test('keeps a triggered action visible once the pointer leaves the row', async ({ page }) => {
+ test.setTimeout(120000);
+
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+
+ /** A second turn demotes the first row out of `isLast`, the only state that
+ * fades the actions at all. */
+ expect((await sendMessageAndWaitForCompletion(page, 'First turn.')).ok()).toBeTruthy();
+ expect((await sendMessageAndWaitForCompletion(page, 'Second turn.')).ok()).toBeTruthy();
+
+ const row = messagesView(page)
+ .locator('.message-render')
+ .filter({ has: page.locator('.user-turn') })
+ .first();
+ const editButton = row.locator('button[id^="edit-"]');
+ const forkButton = row.getByRole('button', { name: 'Open Fork Menu' });
+
+ /** Baseline: an idle action really does fade, so the assertions below are
+ * measuring the active state rather than a row that never hides anything. */
await row.hover();
await expect(editButton).toBeEnabled();
+ await page.mouse.move(0, 0);
+ await expect(editButton).toHaveCSS('opacity', '0');
+
+ await row.hover();
+ await editButton.click();
+ await expect(row.getByTestId('message-text-editor')).toBeVisible();
+ await page.locator('body').click({ position: { x: 5, y: 5 } });
+ await page.mouse.move(0, 0);
+ await expect(row.getByTestId('message-text-editor')).toBeVisible();
await expect(editButton).toHaveCSS('opacity', '1');
+
+ /** Escape only lands while the textarea holds focus, and the pointer left the
+ * row several steps ago, so close the editor through its own control. */
+ await row.hover();
+ await row.getByRole('button', { name: 'Cancel' }).click();
+ await expect(row.getByTestId('message-text-editor')).toHaveCount(0);
+ await page.mouse.move(0, 0);
+ await expect(forkButton).toHaveCSS('opacity', '0');
+
+ /** The fork popover is portalled, so the row holds no focus while it is open. */
+ await row.hover();
+ await forkButton.click();
+ await page.mouse.move(0, 0);
+ await expect(forkButton).toHaveCSS('opacity', '1');
+ });
+
+ /**
+ * Holding only the trigger open leaves the rest of the toolbar faded, so the row
+ * reads as a single floating button while its surface is open. Any active action
+ * keeps every sibling opaque.
+ */
+ test('keeps the whole toolbar visible while one action is open', async ({ page }) => {
+ test.setTimeout(120000);
+
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+
+ expect((await sendMessageAndWaitForCompletion(page, 'First turn.')).ok()).toBeTruthy();
+ expect((await sendMessageAndWaitForCompletion(page, 'Second turn.')).ok()).toBeTruthy();
+
+ const row = messagesView(page)
+ .locator('.message-render')
+ .filter({ has: page.locator('.user-turn') })
+ .first();
+ const editButton = row.locator('button[id^="edit-"]');
+ const forkButton = row.getByRole('button', { name: 'Open Fork Menu' });
+ const copyButton = row.getByRole('button', { name: 'Copy to clipboard' });
+
+ await row.hover();
+ await expect(editButton).toBeEnabled();
+ await page.mouse.move(0, 0);
+ await expect(copyButton).toHaveCSS('opacity', '0');
+ await expect(forkButton).toHaveCSS('opacity', '0');
+
+ await row.hover();
+ await forkButton.click();
+ await page.mouse.move(0, 0);
+
+ await expect(forkButton).toHaveCSS('opacity', '1');
+ await expect(copyButton).toHaveCSS('opacity', '1');
+ await expect(editButton).toHaveCSS('opacity', '1');
+
+ /** Closing by Escape rather than the trigger is the path that used to strand the
+ * fork button in its active state, which would now pin the whole toolbar open. */
+ /** Closing by Escape rather than the trigger is the path that used to strand the
+ * fork button in its active state, which would now pin the whole toolbar open.
+ * Escape hands focus back to the trigger, so drop it before measuring the fade
+ * or `group-focus-within` keeps the row lit on its own. */
+ await page.keyboard.press('Escape');
+ await expect(page.locator('.popover-animate')).toHaveCount(0);
+ await page.locator('body').click({ position: { x: 5, y: 5 } });
+ await page.mouse.move(0, 0);
+ await expect(copyButton).toHaveCSS('opacity', '0');
+ await expect(forkButton).toHaveCSS('opacity', '0');
});
});
diff --git a/e2e/specs/mock/message-edit-layout.spec.ts b/e2e/specs/mock/message-edit-layout.spec.ts
new file mode 100644
index 0000000000..9331c89b66
--- /dev/null
+++ b/e2e/specs/mock/message-edit-layout.spec.ts
@@ -0,0 +1,127 @@
+import { expect, test } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import {
+ MOCK_ENDPOINTS,
+ NEW_CHAT_PATH,
+ messagesView,
+ replyText,
+ selectMockEndpoint,
+ sendMessageAndWaitForCompletion,
+ thinkPrompt,
+ thinkText,
+} from './helpers';
+
+/** The edit surface reports "Unsaved changes" and, for a multi-part response, "Save these
+ * edits first, then rerun the response." Both share the footer's status slot so that
+ * neither can add a row and push the rest of the conversation down while typing. */
+
+const EDIT_SECTION = 'section[aria-label="Edit message"]';
+
+const editorSection = (page: Page) => page.locator(EDIT_SECTION);
+
+type EditMetrics = {
+ footer: number;
+ section: number;
+ status: string;
+};
+
+async function measureEditor(page: Page): Promise {
+ return page.evaluate((selector) => {
+ const section = document.querySelector(selector);
+ if (!section) {
+ throw new Error('edit section not found');
+ }
+ const footer = section.querySelector('footer');
+ if (!footer) {
+ throw new Error('edit footer not found');
+ }
+ const status = footer.querySelector('span');
+ return {
+ footer: Math.round(footer.getBoundingClientRect().height),
+ section: Math.round(section.getBoundingClientRect().height),
+ status: status ? status.textContent.trim() : '',
+ };
+ }, EDIT_SECTION);
+}
+
+async function openChat(page: Page) {
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+}
+
+async function startEditing(page: Page, row: ReturnType) {
+ await row.hover();
+ const editButton = row.locator('button[id^="edit-"]').first();
+ await expect(editButton).toBeEnabled();
+ await editButton.click();
+ await expect(editorSection(page)).toBeVisible();
+ await page.mouse.move(0, 0);
+}
+
+test.describe('message edit layout stability', () => {
+ test('typing in a user message editor does not resize the row', async ({ page }) => {
+ await openChat(page);
+ const response = await sendMessageAndWaitForCompletion(page, 'E2E_REPLY:edit-layout-user');
+ expect(response.ok()).toBeTruthy();
+
+ const row = messagesView(page)
+ .locator('.message-render')
+ .filter({ has: page.locator('.user-turn') })
+ .last();
+ await startEditing(page, row);
+
+ const clean = await measureEditor(page);
+ expect(clean.status).toBe('');
+
+ const editor = row.getByTestId('message-text-editor');
+ await editor.click();
+ await editor.press('End');
+ await editor.type(' plus an edit');
+
+ await expect.poll(async () => (await measureEditor(page)).status).toBe('Unsaved changes');
+
+ const dirty = await measureEditor(page);
+ expect(dirty.footer).toBe(clean.footer);
+ expect(dirty.section).toBe(clean.section);
+ });
+
+ test('the rerun hint shares the status slot without adding a row', async ({ page }) => {
+ test.setTimeout(120000);
+ await openChat(page);
+ const label = 'edit-layout-parts';
+ const response = await sendMessageAndWaitForCompletion(page, thinkPrompt(label));
+ expect(response.ok()).toBeTruthy();
+ await expect(messagesView(page).getByText(replyText(label))).toBeVisible();
+
+ const row = messagesView(page)
+ .locator('.message-render')
+ .filter({ has: page.locator('.agent-turn') })
+ .last();
+ await startEditing(page, row);
+
+ const editors = editorSection(page).getByRole('textbox');
+ await expect(editors).toHaveCount(2);
+
+ const clean = await measureEditor(page);
+ expect(clean.status).toBe('');
+
+ /** One changed part is just an unsaved edit; the second is what gates rerun. */
+ await editors.nth(0).fill(`${thinkText(label)} revised`);
+ await expect.poll(async () => (await measureEditor(page)).status).toBe('Unsaved changes');
+ const single = await measureEditor(page);
+
+ await editors.nth(1).fill(`${replyText(label)} revised`);
+ await expect
+ .poll(async () => (await measureEditor(page)).status)
+ .toBe('Rerunning applies one edited section at a time. Save to keep all of these changes.');
+ const both = await measureEditor(page);
+
+ expect(single.footer).toBe(clean.footer);
+ expect(both.footer).toBe(clean.footer);
+ expect(both.section).toBe(single.section);
+
+ await expect(
+ editorSection(page).getByRole('button', { name: 'Update & rerun' }),
+ ).toBeDisabled();
+ });
+});
diff --git a/e2e/specs/mock/message-tree.spec.ts b/e2e/specs/mock/message-tree.spec.ts
index 8ac3959c2c..922dda45a3 100644
--- a/e2e/specs/mock/message-tree.spec.ts
+++ b/e2e/specs/mock/message-tree.spec.ts
@@ -1031,7 +1031,7 @@ test.describe('message tree stream operations', () => {
await expect(editor).toBeVisible();
await editor.fill(editedMiddlePrompt);
await waitForGenerationStart(page, () =>
- page.getByRole('button', { name: 'Save & Submit' }).click(),
+ page.getByRole('button', { name: 'Update & rerun' }).click(),
);
await expect(messagesView(page).getByText(editedMiddleReply)).toBeVisible({ timeout: 30000 });
diff --git a/e2e/specs/mock/message-visual.spec.ts b/e2e/specs/mock/message-visual.spec.ts
new file mode 100644
index 0000000000..afb30be0e9
--- /dev/null
+++ b/e2e/specs/mock/message-visual.spec.ts
@@ -0,0 +1,213 @@
+import { expect, test } from '@playwright/test';
+import type { Locator, Page } from '@playwright/test';
+import {
+ MOCK_ENDPOINTS,
+ NEW_CHAT_PATH,
+ messagesView,
+ replyPrompt,
+ replyText,
+ selectMockEndpoint,
+ sendMessage,
+ sendMessageAndWaitForCompletion,
+} from './helpers';
+
+type VisualTheme = 'light' | 'dark';
+type VisualViewport = {
+ height: number;
+ name: 'desktop' | 'mobile';
+ snapshotSuffix: '' | '-mobile';
+ width: number;
+};
+
+const THEMES: VisualTheme[] = ['light', 'dark'];
+const VIEWPORTS: VisualViewport[] = [
+ { name: 'desktop', width: 1280, height: 900, snapshotSuffix: '' },
+ { name: 'mobile', width: 390, height: 844, snapshotSuffix: '-mobile' },
+];
+const PROVIDER_C = { label: 'Mock Provider C', model: 'mock-model-c' };
+const MCP_SERVER_TITLE = 'E2E Memory';
+const VISUAL_OPTIONS = {
+ animations: 'disabled' as const,
+ caret: 'hide' as const,
+ maxDiffPixels: 20,
+ scale: 'css' as const,
+};
+
+/**
+ * Pixel baselines only compare cleanly against the machine that produced them, and this
+ * repository tracks none. Until baselines are generated on the runner image itself, the
+ * flows below still run and assert their structure, while the screenshot comparison is
+ * opt-in through `E2E_VISUAL_SNAPSHOTS=1 npx playwright test --config=e2e/playwright.config.mock.ts --update-snapshots`.
+ */
+const VISUAL_BASELINES_ENABLED = process.env.E2E_VISUAL_SNAPSHOTS === '1';
+
+const messageRows = (page: Page) => messagesView(page).locator('.message-render');
+const userRow = (page: Page) =>
+ messageRows(page)
+ .filter({ has: page.locator('.user-turn') })
+ .last();
+const assistantRow = (page: Page) =>
+ messageRows(page)
+ .filter({ has: page.locator('.agent-turn') })
+ .last();
+const stopButton = (page: Page) => page.getByRole('button', { name: 'Stop generating' });
+
+async function openChat(page: Page, theme: VisualTheme, viewport: VisualViewport) {
+ await page.addInitScript((selectedTheme: VisualTheme) => {
+ localStorage.setItem('color-theme', selectedTheme);
+ localStorage.removeItem('theme-definition');
+ localStorage.removeItem('theme-colors');
+ localStorage.removeItem('theme-name');
+ localStorage.removeItem('theme-source');
+ }, theme);
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
+ await expect(page.locator('html')).toHaveClass(new RegExp(`(^|\\s)${theme}(\\s|$)`));
+}
+
+async function expectMessageScreenshot(locator: Locator, name: string) {
+ await expect(locator).toBeVisible();
+ await locator.scrollIntoViewIfNeeded();
+ await locator.page().evaluate(async () => {
+ await document.fonts.ready;
+ });
+ if (!VISUAL_BASELINES_ENABLED) {
+ return;
+ }
+ await expect(locator).toHaveScreenshot(name, VISUAL_OPTIONS);
+}
+
+async function selectEphemeralMCP(page: Page) {
+ await page.getByRole('button', { name: 'MCP Servers', exact: true }).click();
+ const serverItem = page.getByRole('menuitemcheckbox', {
+ name: new RegExp(MCP_SERVER_TITLE),
+ });
+ await expect(serverItem).toBeVisible();
+ await serverItem.click();
+ await expect(serverItem).toHaveAttribute('aria-checked', 'true');
+ await page.keyboard.press('Escape');
+}
+
+test.skip(process.platform !== 'linux', 'Message visual baselines target the Linux CI runner');
+
+for (const viewport of VIEWPORTS) {
+ for (const theme of THEMES) {
+ test.describe(`${theme} ${viewport.name} message visuals`, () => {
+ test(`captures normal user and assistant messages`, async ({ page }) => {
+ await openChat(page, theme, viewport);
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+
+ const normalPrompt =
+ viewport.name === 'mobile'
+ ? 'Give me a concise plan for a calm morning before a busy day with several appointments.'
+ : 'Give me a concise plan for a calm morning.';
+ const response = await sendMessageAndWaitForCompletion(page, normalPrompt);
+ expect(response.ok()).toBeTruthy();
+
+ await expectMessageScreenshot(
+ userRow(page),
+ `message-normal-user-${theme}${viewport.snapshotSuffix}.png`,
+ );
+ await expectMessageScreenshot(
+ assistantRow(page),
+ `message-normal-assistant-${theme}${viewport.snapshotSuffix}.png`,
+ );
+ });
+
+ test(`captures an active streaming response`, async ({ page }) => {
+ test.setTimeout(60000);
+ await openChat(page, theme, viewport);
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+
+ const response = await sendMessage(
+ page,
+ `E2E_EMPTY_SLOW_REPLY:message-visual-stream-${viewport.name}`,
+ );
+ expect(response.ok()).toBeTruthy();
+ await expect(stopButton(page)).toBeVisible();
+
+ await expectMessageScreenshot(
+ assistantRow(page),
+ `message-streaming-${theme}${viewport.snapshotSuffix}.png`,
+ );
+
+ await stopButton(page).click();
+ await expect(stopButton(page)).toBeHidden({ timeout: 30000 });
+ });
+
+ test(`captures a user message in edit mode`, async ({ page }) => {
+ await openChat(page, theme, viewport);
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+
+ const editPrompt =
+ viewport.name === 'mobile'
+ ? 'Turn this longer mobile message into an editable draft that wraps across multiple lines.'
+ : 'Turn this message into an editable draft.';
+ const response = await sendMessageAndWaitForCompletion(page, editPrompt);
+ expect(response.ok()).toBeTruthy();
+
+ const row = userRow(page);
+ await row.hover();
+ const editButton = row.locator('button[id^="edit-"]');
+ await expect(editButton).toBeEnabled();
+ await editButton.click();
+ await expect(row.getByTestId('message-text-editor')).toBeVisible();
+ await page.mouse.move(0, 0);
+
+ await expectMessageScreenshot(
+ row,
+ `message-editing-${theme}${viewport.snapshotSuffix}.png`,
+ );
+ });
+
+ test(`captures an applied steer message`, async ({ page }) => {
+ test.setTimeout(150000);
+ const setupLabel = `message-visual-steer-setup-${viewport.name}`;
+ const runLabel = `message-visual-steer-${viewport.name}`;
+ const steerText =
+ viewport.name === 'mobile'
+ ? 'Prioritize the three most important steps and keep each one concise.'
+ : 'Prioritize the three most important steps.';
+
+ const setupViewport = viewport.name === 'mobile' ? VIEWPORTS[0] : viewport;
+ await openChat(page, theme, setupViewport);
+ await selectMockEndpoint(page, PROVIDER_C);
+ await selectEphemeralMCP(page);
+
+ const setupResponse = await sendMessageAndWaitForCompletion(page, replyPrompt(setupLabel));
+ expect(setupResponse.ok()).toBeTruthy();
+ await expect(messagesView(page).getByText(replyText(setupLabel))).toBeVisible();
+
+ const runResponse = await sendMessage(page, `E2E_STEER_TOOL_REPLY:${runLabel}`);
+ expect(runResponse.ok()).toBeTruthy();
+
+ const input = page.getByRole('textbox', { name: 'Message input' });
+ await input.fill(steerText);
+ const duringRunSendButton = page.getByTestId('during-run-send-button');
+ await expect(duringRunSendButton).toHaveAttribute('data-during-run-action', 'steer');
+ await input.press('Enter');
+
+ const steerPart = messagesView(page)
+ .getByTestId('steer-part')
+ .filter({ hasText: steerText });
+ await expect(steerPart).toHaveCount(1, { timeout: 60000 });
+ await expect(
+ messagesView(page).getByText(`E2E steer tool reply done ${runLabel}`),
+ ).toBeVisible({
+ timeout: 60000,
+ });
+
+ await page.setViewportSize({ width: viewport.width, height: viewport.height });
+ const closeSidebarButton = page.getByTestId('close-sidebar-button');
+ if (viewport.name === 'mobile' && (await closeSidebarButton.isVisible())) {
+ await closeSidebarButton.click();
+ }
+
+ await expectMessageScreenshot(
+ steerPart,
+ `message-steered-${theme}${viewport.snapshotSuffix}.png`,
+ );
+ });
+ });
+ }
+}