From d920328bfa53b4eda2140408d12812671caed83e Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Fri, 14 Aug 2026 01:30:39 +0200
Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AC=20style:=20Unify=20Message=20Row?=
=?UTF-8?q?=20Layout=20and=20Edit=20Surfaces=20(#14770)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* style: Unify message row layout and edit surfaces
Route chat, share, and search messages through a shared MessageRow so
user turns render as right-aligned bubbles and assistant turns keep a
visible identity column.
Replace per-part text editors with one edit surface that keeps tools,
errors, and artifacts visible. Preserve non-text fields when saving
content parts, copy the full serialized message, and hide hover actions
that do not apply during streaming or errors.
* style: Align edit footer and lighten editor field in dark mode
Drop the divider above the user edit footer so both edit surfaces share
the same footer treatment.
Move the editor fields to surface-tertiary-alt. Light mode is unchanged
at #fff, while dark mode lifts from #0d0d0d to #2f2f2f so the field sits
above the #212121 panel instead of sinking into near-black.
* style: Drop focus border and ring from message editors
The editor fields changed border color and added a ring on focus. Keep
the border static and rely on the app-level focus handling instead.
* fix: Keep a triggered message action visible when the row is not hovered
Hover actions fade out on non-last rows, and mobile.css only restored
display and visibility for an active button, never opacity. Opening the
fork popover therefore left it anchored to an invisible trigger once the
pointer left the row. Skip the fade entirely while a button is active.
Extract the recipe the three toolbars repeated so the rule has one home.
Rework the streaming guard to the contract the toolbar now implements:
edit and fork are omitted from a streaming response rather than rendered
disabled, and the settled turn above keeps its own actions. It asserted
the removed disabled-and-transparent behaviour and its opacity check only
held because the growing response shifted the row out from under the
pointer.
* style: Trim message edit chrome and stabilize the status row
The edit surface was a titled card sitting inside the conversation: a
bordered panel with an "Edit message" heading wrapping bordered fields,
which read as a settings dialog rather than an inline editor. Drop the
card background, border and heading, and take the footer buttons down to
the small size so the editor reads as a field in the message flow. The
captured row goes from 253px to 187px.
Move "Unsaved changes" into the footer and merge the rerun hint into the
same slot. Both previously added their own row, so typing pushed the rest
of the conversation down. The slot is clamped to two lines, which stays
under the 36px button row, so the footer height holds at 36px regardless
of which message is showing.
* test: Cover message edit layout stability
Add a mock e2e spec that measures the edit footer and section boxes and
asserts they hold steady as the status text appears, for both the
single-part user editor and a multi-part response.
The multi-part case needs an assistant message with two editable parts,
so add an E2E_THINK_REPLY marker to the fake model. Its think tags are
parsed downstream by the agents stream pipeline, which yields a reasoning
part followed by a text part.
* fix: Read the fork popover open state from its store
Fork mirrored the popover state into its own useState and reset it from an
onClose prop. Ariakit 0.4 has no onClose, and React's DOM types accept the
name on any element, so it type-checked, landed on a div and never fired.
Closing by Escape or an outside click therefore left the button reading as
active until the trigger was clicked again.
Read the state from the store instead so every close path clears it.
* fix: Keep the whole toolbar visible while an action is open
Only the triggered button escaped the hover fade, so opening the editor or
the fork popover left the row as a single floating button once the pointer
moved away. Mark the active button and have every action in the toolbar key
off it, so the group stays opaque for as long as a surface is open.
The marker is a dedicated class rather than the existing `active`, which
HoverButtons pins to the edit button of every assistant message and would
hold those toolbars open permanently.
The existing guard pressed Escape to close the editor while focus sat on the
body, so the editor never closed and its assertion only held because the
sibling faded regardless. Close the editor through its own control, and drop
focus before measuring the fade now that Escape returns it to the trigger.
* fix: Withhold copy while a response is still streaming
Text-to-speech, fork and feedback were all withheld from a message that is
still generating, but copy was rendered throughout, so the button offered to
put half a sentence on the clipboard. Gate it on the same condition.
That empties the toolbar for the duration, and SubRow collapses an empty row,
so a streaming response now carries no actions at all until it settles. Both
guards encoded the old contract: the unit test asserted copy was present and
counted a single button, and the browser guard used copy as its proof that the
toolbar had mounted. The settled turn above takes over that role.
* fix: Move retry navigation to the outer edge of a user turn
A user turn is right-aligned, but its sibling navigation rendered ahead of the
actions, so the retry counter sat inboard of the icons instead of under the
edge of the bubble it belongs to. Order it last on user turns.
* fix: Ride the stream instead of chasing it
Following a generating answer went through a helper throttled at 145ms, so the
thread caught up in visible jerks rather than flowing. It now writes the scroll
position directly on each frame, which is what an answer arriving a few pixels
at a time actually needs, and glides only for the one long trip a turn makes,
when sending has to travel from wherever the reader was down to the newest
word.
Whether to follow at all is now answered by where the reader is and which way
they were going, rather than by the abort flag. `useMessageProcess` raises that
flag on any wheel at all, downward ones included, through a throttle whose
trailing call lands after the gesture has ended, so nothing timed to the
gesture could outlive it. Scrolling down to the newest word could therefore
never resume the ride, while the scroll-to-bottom button, which touches no
wheel, always could.
Arrival is judged on the scroll it produces rather than the wheel tick that
started it, because wheel scrolling is animated and at tick time the thread is
still far short of where the tick is taking it. Arriving also counts from
further out than leaving does: while an answer streams the end recedes between
the last tick and the frame that measures it, so judging arrival as tightly as
departure leaves a reader unable to catch it at all.
* fix: Reveal retry navigation on hover while an answer generates
Copy, edit, fork and read-aloud are all withheld from a response that is still
generating, which left the retry counter as the only thing rendering under a
half-written answer. It now reveals on hover there, like the actions it sits
with, and stays put on a settled turn.
* fix: Keep a refused rerun from discarding the edit
While a response is streaming, the edit action stays available on every earlier
row, and those editors see a per-message submitting flag that is false, so
Update and rerun is enabled. The send itself is still refused: ask() returns
false for the duration of the active submission. Both editors ignored that and
closed anyway, so the draft went with them and no rerun ever started.
Both rerun paths now check the result and leave the editor untouched when the
send is refused, so the work survives until the thread is free.
* fix: Let an upward gesture beat the pending send glide
Sending arms a smooth glide down to the newest word, and the landing re-pins the
thread to the bottom. The landing was scheduled two ways, on scrollend and on a
700ms fallback, and neither was ever cancelled. A reader who changed their mind
and headed up mid-flight was pinned again regardless, then dragged back by the
next streaming resize. The fallback fires for the whole window, so this held even
after the glide had visibly settled.
The gesture now marks the glide interrupted, wherever it lets go of the bottom,
and the landing stands down when it sees that. A glide the reader leaves alone
still re-affirms the ride.
* fix: Fade retry navigation on every streaming response format
Every other action is withheld from the row that is still generating, so the
retry counter is the only thing left under a half-written answer. The plain text
row already faded it to hover-only there; the structured rows did not, and left
it sitting on its own.
Both structured paths now apply the same condition, and the class string the
three of them share moves next to the hover action styles it belongs with.
* i18n: Correct the copy the edit surface rewrite left behind
The multi-part hint told the reader to save first and then rerun, but a save
closes the editor and reopening seeds the drafts from what was just saved, so
there is nothing left to rerun and the button stays disabled. Rerunning carries a
single edited section by design, so the hint now states that limit rather than
pointing at a step that is not there.
Drop com_ui_save_submit as well: the per-part editor that used it is gone.
* test: Make the message visual baselines opt-in
The suite asserts sixteen screenshots and the repository tracks none, so
Playwright's default treats every one as a miss and the mock e2e job fails on
Linux. Baselines only compare cleanly against the machine that produced them, and
nothing here can generate ones that match the runner image.
The flows keep running and asserting their structure, which is where their value
was; only the pixel comparison is now gated behind E2E_VISUAL_SNAPSHOTS.
* style: Restore import order in the reworked message files
The repository sorter and CI disagreed with what these files were left holding
after the edit surface rework. No behavior change.
* test: Follow the reworded rerun hint in the edit layout spec
The multi-part hint was restated in the previous commit; this assertion still
expected the old wording and would have failed the mock e2e suite.
* fix: Leave the send glide alone while the answer streams in
Every delta of an answer reruns the scroll effect, and the plain follow writes
scrollTop outright, which cancels an animation on its first frame. So the glide a
send starts was killed by the first token to arrive and the reader was snapped
down instead of carried.
The follow now stands down while a glide is travelling, which is what the hook
already documented but only enforced on the resize path.
* fix: Write a saved edit onto the thread as it stands
An earlier turn stays editable while the newest answer streams, and the save
captured the thread before the request but wrote it back after. Every delta that
landed during the round trip was overwritten. Most of the time the next delta
re-merged and the damage showed as a one-frame truncation, but a save that
resolved after the stream's final write left the cache wrong for the rest of the
session.
The thread is now read once the request has resolved, which is what the content
part editor already did.
The editor actions in this file also wrap again rather than hold one unbreakable
row, for the reason given in the following commit.
* fix: Let the editor actions wrap on a narrow row
At 320px an assistant turn gives the editor about 252px once page padding, the
identity column and the row gap are taken out, and Cancel, Save and Update &
rerun need more than that in English alone. The group was pinned with shrink-0,
so it ran past the edge of the row instead of wrapping. A longer translated label
makes it worse, and the user turn had no margin left either.
Both editors wrap again, which is what the footer did before the status row was
folded into it.
* fix: Catch up to the new bottom when the glide lands
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. A short
response that finished before the glide reported landing left the thread a few
lines short of its own end, with nothing left to correct it.
Landing now closes whatever gap opened, unless the reader took over on the way.
* test: Follow the renamed rerun button in the edit flow specs
The button became 'Update & rerun' when the edit surfaces were unified, but two
edit-flow specs still located 'Save & Submit' and would have waited for it until
they timed out. A type comment named the old button too.
* fix: Judge the first thread scroll against a real position
The direction check seeded its last-position ref at 0, so the first scroll
event on an opened thread, which arrives carrying a large positive
scrollTop, read as a jump downward. Near the end that cleared the abort
flag and re-pinned a reader to the stream they were scrolling away from.
Take the first event as a baseline and judge direction from the next.
* fix: Hold the content part editor to what it replaced
EditContentParts took over from EditTextPart and left two of its behaviors
behind.
An emptied box now blocks Save and rerun instead of persisting a blank
part. EditTextPart refused the same edit through its form's required rule
and the sibling EditMessage still does, so both editors hold one line. The
keyboard shortcuts reach the save paths directly, so they are guarded
there too, and the footer says why the buttons are down.
The editor also follows the chat direction again, taking dir and text
alignment from the same setting EditMessage reads.
* fix: Hold the footer height while a response streams
Every action is withheld from the row that is still generating, and a lone
sibling counter renders nothing, so the footer measured zero until the answer
landed and then sprang to the height of the buttons. The transcript stepped
upward under the reader at the moment a response completed.
The placeholder that used to reserve this space went when the footer became
unconditional, so hold the height on the row itself instead.
* fix: Remember where the thread was put before judging a gesture
Direction is judged against the last sample, and the thread is placed at its
end without the reader touching it. With no record of where it was put, their
first gesture was spent taking the baseline instead of being obeyed: a single
PageUp cleared no flag of its own, so the next streamed resize rode the reader
straight back to the end they were leaving.
Every programmatic move now records the position it left the thread at, so the
sentinel stands only until something has actually placed it.
* fix: Spend the start of a turn only once it can be honored
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 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 honor, the reader was still detached, and the answer they had just asked for
streamed on offscreen.
Record the turn as started only on the pass that acts on it.
* fix: Show the part edits that survived a refused save
The editor saves every changed part through one button, but the endpoint
takes a single part per call and nothing rolls a write back. A part the
server refused therefore left the earlier ones stored while the editor
reported that the message could not be saved, so cancelling from there
walked away from edits that were already live.
Record the writes that landed and reconcile the transcript with them
whichever way the save ended. The refused parts are the only ones left
holding a draft, so a retry no longer rewrites what already arrived.
* fix: Stop a shared transcript from calling the sharer the reader
The share row reused the chat view's user label, which reads "You". It is
the screen-reader heading for the user turn, so anyone opening a share
link heard every prompt the sharer wrote credited to themselves.
Use the neutral "User" label on this surface. It keeps the localization
the row gained, unlike the untranslated string it replaced.
* fix: Let go of the stream when an interaction settles over several resizes
Expanding a tool result mid-answer renders the container first and fills it
once its contents arrive, so one gesture produces more than one resize. Only
the first was credited to the interaction. The second read the reader as still
riding the stream and put them back on the bottom they had just left.
The suppressed resize now settles the ride as well as the near-bottom measure,
using the position the interaction actually left the reader at, so an
interaction that kept them on the end still streams.
* fix: Edit inside a structured text part instead of flattening it
A text content part holds either a string or a { value, annotations } object.
The Assistants thread sync persists the structured form with its file
citations intact, and the editor reads the part through the same union, so
saving an edit wrote a bare string over the whole object and took every
citation with it.
The same object was handed to the tokenizer, which measures length, so a part
that had been edited this way also stored a NaN token count. Write the edit
into value, keep the rest of the part, and count the text itself.
* fix: Keep a saved part's citations in the transcript it is written back to
A text or think part holds either a bare string or a { value, annotations }
object, and the editor already read both through getPartText. Writing the
draft back into the local message cache put the string over the whole value,
so a response carrying file citations lost them the moment it was edited and
did not get them back until a refetch.
Reading and writing now go through the same accessor, so an edit lands in the
shape it was read from and the rest of the part survives.
* fix: Let the message editor follow the chosen font size
Editing a message dropped the draft to a fixed 14px regardless of the
Font Size setting. On dev the textarea carried the markdown class, so it
read --markdown-font-size like the rendered message does; restyling it
into a bordered box replaced that with text-sm, and the new per-part
editor was written the same way. Anyone on Extra Small, Large or Extra
Large saw the text jump the moment they entered edit mode.
Share the .message-content typography with the editors through a
message-editor-text class so a draft is sized like the message it
replaces and keeps tracking the setting.
---
api/app/clients/BaseClient.js | 3 +-
api/app/clients/specs/BaseClient.test.js | 37 ++
.../__tests__/messages-content-edit.spec.js | 144 ++++++
api/server/routes/messages.js | 15 +-
client/src/common/types.ts | 2 +-
.../Chat/Messages/Content/ContentParts.tsx | 56 +--
.../Messages/Content/EditContentParts.tsx | 444 ++++++++++++++++++
.../Chat/Messages/Content/EditMessage.tsx | 186 +++++---
.../Messages/Content/Parts/AuthorHeader.tsx | 8 +-
.../Messages/Content/Parts/EditTextPart.tsx | 213 ---------
.../Chat/Messages/Content/Parts/SteerPart.tsx | 69 +--
.../Parts/__tests__/SteerPart.test.tsx | 29 +-
.../Chat/Messages/Content/Parts/index.ts | 1 -
.../ContentParts.integration.test.tsx | 1 -
.../Content/__tests__/ContentParts.test.tsx | 1 -
.../__tests__/EditContentParts.spec.tsx | 439 +++++++++++++++++
.../Content/__tests__/EditMessage.spec.tsx | 222 +++++++++
.../src/components/Chat/Messages/Feedback.tsx | 14 +-
client/src/components/Chat/Messages/Fork.tsx | 19 +-
.../components/Chat/Messages/HoverButtons.tsx | 94 ++--
.../src/components/Chat/Messages/Message.tsx | 4 +-
.../components/Chat/Messages/MessageParts.tsx | 173 +++----
.../Chat/Messages/SearchMessage.tsx | 64 +--
.../Chat/Messages/SiblingSwitch.tsx | 10 +-
.../__tests__/HoverActions.streaming.spec.tsx | 114 ++++-
.../Messages/__tests__/HoverButtons.spec.tsx | 87 +++-
.../Chat/Messages/__tests__/styles.spec.ts | 45 ++
client/src/components/Chat/Messages/styles.ts | 57 +++
.../Chat/Messages/ui/MessageRender.tsx | 164 +++----
.../Chat/Messages/ui/MessageRow.tsx | 111 +++++
.../Chat/Messages/ui/PlaceholderRow.tsx | 9 -
.../Messages/ui/__tests__/MessageRow.spec.tsx | 101 ++++
.../Endpoints/MessageEndpointIcon.spec.tsx | 31 ++
.../Endpoints/MessageEndpointIcon.tsx | 5 +-
.../src/components/Messages/ContentRender.tsx | 163 +++----
.../components/Messages/MessageContent.tsx | 4 +-
client/src/components/Share/Message.tsx | 144 +++---
.../__tests__/useMessageScrolling.spec.tsx | 310 +++++++++++-
.../hooks/Messages/useCopyToClipboard.spec.ts | 31 +-
.../src/hooks/Messages/useCopyToClipboard.ts | 49 +-
.../src/hooks/Messages/useMessageScrolling.ts | 293 +++++++++++-
client/src/hooks/useGenerationsByLatest.ts | 7 +-
client/src/locales/en/translation.json | 6 +-
client/src/style.css | 5 +-
e2e/setup/fake-model.js | 10 +
e2e/specs/messages.spec.ts | 2 +-
e2e/specs/mock/helpers.ts | 4 +
e2e/specs/mock/hover-actions.spec.ts | 157 ++++++-
e2e/specs/mock/message-edit-layout.spec.ts | 127 +++++
e2e/specs/mock/message-tree.spec.ts | 2 +-
e2e/specs/mock/message-visual.spec.ts | 213 +++++++++
51 files changed, 3463 insertions(+), 1036 deletions(-)
create mode 100644 api/server/routes/__tests__/messages-content-edit.spec.js
create mode 100644 client/src/components/Chat/Messages/Content/EditContentParts.tsx
delete mode 100644 client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx
create mode 100644 client/src/components/Chat/Messages/Content/__tests__/EditContentParts.spec.tsx
create mode 100644 client/src/components/Chat/Messages/Content/__tests__/EditMessage.spec.tsx
create mode 100644 client/src/components/Chat/Messages/__tests__/styles.spec.ts
create mode 100644 client/src/components/Chat/Messages/styles.ts
create mode 100644 client/src/components/Chat/Messages/ui/MessageRow.tsx
delete mode 100644 client/src/components/Chat/Messages/ui/PlaceholderRow.tsx
create mode 100644 client/src/components/Chat/Messages/ui/__tests__/MessageRow.spec.tsx
create mode 100644 client/src/components/Endpoints/MessageEndpointIcon.spec.tsx
create mode 100644 e2e/specs/mock/message-edit-layout.spec.ts
create mode 100644 e2e/specs/mock/message-visual.spec.ts
diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js
index 19d4619bad..58d111a32a 100644
--- a/api/app/clients/BaseClient.js
+++ b/api/app/clients/BaseClient.js
@@ -591,7 +591,8 @@ class BaseClient {
} else if (editedContent != null) {
// Handle editedContent for content parts
if (editedContent && latestMessage.content && Array.isArray(latestMessage.content)) {
- const { index, text, type } = editedContent;
+ const { index, type } = editedContent;
+ const text = editedContent[type];
if (index >= 0 && index < latestMessage.content.length) {
const contentPart = latestMessage.content[index];
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js
index 77848851a2..70f0a91c17 100644
--- a/api/app/clients/specs/BaseClient.test.js
+++ b/api/app/clients/specs/BaseClient.test.js
@@ -691,6 +691,43 @@ describe('BaseClient', () => {
);
});
+ it('applies edited reasoning content from its typed payload before regeneration', async () => {
+ const responseMessageId = 'response-with-reasoning';
+ const newHistory = [
+ ...messageHistory,
+ {
+ role: 'assistant',
+ isCreatedByUser: false,
+ messageId: responseMessageId,
+ parentMessageId: '3',
+ content: [
+ { type: ContentTypes.THINK, think: 'Original reasoning', phase: 'analysis' },
+ { type: ContentTypes.TEXT, text: 'Original response' },
+ ],
+ },
+ ];
+
+ TestClient = initializeFakeClient(apiKey, options, newHistory);
+ await TestClient.sendMessage('test message', {
+ isEdited: true,
+ overrideParentMessageId: 'user-message-id',
+ parentMessageId: '3',
+ responseMessageId,
+ editedContent: {
+ index: 0,
+ type: ContentTypes.THINK,
+ [ContentTypes.THINK]: 'Updated reasoning',
+ },
+ });
+
+ const editedResponse = TestClient.currentMessages[TestClient.currentMessages.length - 1];
+ expect(editedResponse.content[0]).toEqual({
+ type: ContentTypes.THINK,
+ think: 'Updated reasoning',
+ phase: 'analysis',
+ });
+ });
+
test('setOptions is called with the correct arguments only when replaceOptions is set to true', async () => {
TestClient.setOptions = jest.fn();
const opts = { conversationId: '123', parentMessageId: '456', replaceOptions: true };
diff --git a/api/server/routes/__tests__/messages-content-edit.spec.js b/api/server/routes/__tests__/messages-content-edit.spec.js
new file mode 100644
index 0000000000..5851c2a781
--- /dev/null
+++ b/api/server/routes/__tests__/messages-content-edit.spec.js
@@ -0,0 +1,144 @@
+const express = require('express');
+const request = require('supertest');
+const { ContentTypes } = require('librechat-data-provider');
+
+jest.mock('@librechat/agents', () => ({
+ sleep: jest.fn(),
+}));
+
+jest.mock('@librechat/api', () => ({
+ unescapeLaTeX: jest.fn((value) => value),
+ countTokens: jest.fn().mockResolvedValue(2),
+ sendFeedbackScore: jest.fn().mockResolvedValue(undefined),
+ traceIdForMessage: jest.fn((messageId) => `trace-${messageId}`),
+ mergeQuotedTextForCount: jest.fn((text) => text),
+}));
+
+jest.mock('@librechat/data-schemas', () => ({
+ ...jest.requireActual('@librechat/data-schemas'),
+ logger: {
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ },
+}));
+
+jest.mock('~/models', () => ({
+ getMessages: jest.fn(),
+ updateMessage: jest.fn(),
+}));
+
+jest.mock('~/server/services/Artifacts/update', () => ({
+ findAllArtifacts: jest.fn(),
+ replaceArtifactContent: jest.fn(),
+}));
+
+jest.mock('~/server/middleware', () => ({
+ requireJwtAuth: (req, res, next) => next(),
+ validateMessageReq: (req, res, next) => next(),
+ configMiddleware: (req, res, next) => next(),
+ sendValidationResponse: jest.fn(),
+ prepareMessageRequestValidation: jest.fn(),
+}));
+
+describe('PUT /:conversationId/:messageId content edit', () => {
+ let app;
+ const { getMessages, updateMessage } = require('~/models');
+
+ beforeAll(() => {
+ const messagesRouter = require('../messages');
+ app = express();
+ app.use(express.json());
+ app.use((req, res, next) => {
+ req.user = { id: 'user-1' };
+ next();
+ });
+ app.use('/api/messages', messagesRouter);
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ updateMessage.mockResolvedValue({ messageId: 'message-1' });
+ });
+
+ it('preserves content-part metadata when editing its text', async () => {
+ getMessages.mockResolvedValue([
+ {
+ tokenCount: 10,
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ text: 'Original response',
+ phase: 'commentary',
+ agentId: 'agent-1',
+ tool_call_ids: ['tool-1'],
+ },
+ ],
+ },
+ ]);
+
+ const response = await request(app)
+ .put('/api/messages/conversation-1/message-1')
+ .send({ index: 0, text: 'Edited response', model: 'gpt-5' });
+
+ expect(response.status).toBe(200);
+ expect(updateMessage).toHaveBeenCalledWith('user-1', {
+ messageId: 'message-1',
+ tokenCount: 10,
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ text: 'Edited response',
+ phase: 'commentary',
+ agentId: 'agent-1',
+ tool_call_ids: ['tool-1'],
+ },
+ ],
+ });
+ });
+
+ /**
+ * A text part is `string | { value, annotations }`. The Assistants thread sync
+ * persists the structured form with its file citations intact and the editor reads
+ * it through the same union, so writing the edit straight over the object dropped
+ * every citation. Counting the object rather than its value is the same mistake
+ * read back: the tokenizer measures `text.length`, which an object does not have,
+ * so the stored count became NaN.
+ */
+ it('edits inside a structured text part instead of flattening it', async () => {
+ const { countTokens } = require('@librechat/api');
+ const annotations = [
+ { type: 'file_citation', text: 'source', file_citation: { file_id: 'file-1' } },
+ ];
+
+ getMessages.mockResolvedValue([
+ {
+ tokenCount: 10,
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ text: { value: 'Original response', annotations },
+ },
+ ],
+ },
+ ]);
+
+ const response = await request(app)
+ .put('/api/messages/conversation-1/message-1')
+ .send({ index: 0, text: 'Edited response', model: 'gpt-5' });
+
+ expect(response.status).toBe(200);
+ expect(updateMessage).toHaveBeenCalledWith('user-1', {
+ messageId: 'message-1',
+ tokenCount: 10,
+ content: [
+ {
+ type: ContentTypes.TEXT,
+ text: { value: 'Edited response', annotations },
+ },
+ ],
+ });
+ expect(countTokens).toHaveBeenCalledWith('Original response', 'gpt-5');
+ });
+});
diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js
index 0425b40eaa..0894e9bd1d 100644
--- a/api/server/routes/messages.js
+++ b/api/server/routes/messages.js
@@ -409,8 +409,19 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) =
return res.status(400).json({ error: 'Cannot update non-text content' });
}
- const oldText = updatedContent[index][currentPartType];
- updatedContent[index] = { type: currentPartType, [currentPartType]: text };
+ /** A text part is `string | { value, annotations }`. The Assistants thread sync
+ * persists the structured form with its file citations intact, and the editor
+ * reads it through the same union, so an edit has to be written into `value`
+ * rather than over the whole part. The same object is what gets counted below,
+ * and the tokenizer measures `length`, which an object does not have. */
+ const currentPart = updatedContent[index];
+ const currentValue = currentPart[currentPartType];
+ const isStructuredValue = currentValue != null && typeof currentValue === 'object';
+ const oldText = isStructuredValue ? (currentValue.value ?? '') : currentValue;
+ updatedContent[index] = {
+ ...currentPart,
+ [currentPartType]: isStructuredValue ? { ...currentValue, value: text } : text,
+ };
let tokenCount = message.tokenCount;
if (tokenCount !== undefined) {
diff --git a/client/src/common/types.ts b/client/src/common/types.ts
index 07d7544de9..5fae782858 100644
--- a/client/src/common/types.ts
+++ b/client/src/common/types.ts
@@ -352,7 +352,7 @@ export type TOptions = {
isContinued?: boolean;
isEdited?: boolean;
overrideMessages?: t.TMessage[];
- /** This value is only true when the user submits a message with "Save & Submit" for a user-created message */
+ /** This value is only true when the user submits a message with "Update & rerun" for a user-created message */
isResubmission?: boolean;
/** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */
overrideFiles?: t.TMessage['files'];
diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx
index 870e6d6cd2..7656949a65 100644
--- a/client/src/components/Chat/Messages/Content/ContentParts.tsx
+++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx
@@ -11,10 +11,11 @@ import type { ToolCallGroupExpansionState } from './ToolCallGroup';
import { mapAttachments, filterAttachmentsForPart, groupSequentialToolCalls } from '~/utils';
import { groupActivityPhases, lastVisibleContentIdx } from '~/utils/activityLabels';
import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent';
-import { EditTextPart, EmptyText, AgentUpdate } from './Parts';
import { MessageContext, SearchContext } from '~/Providers';
import PendingSkillCall from './Parts/PendingSkillCall';
import ActivityPhaseGroup from './ActivityPhaseGroup';
+import EditContentParts from './EditContentParts';
+import { EmptyText, AgentUpdate } from './Parts';
import ApprovalProvider from './ApprovalContext';
import MemoryArtifacts from './MemoryArtifacts';
import Sources from '~/components/Web/Sources';
@@ -430,45 +431,24 @@ const ContentParts = memo(function ContentParts({
return null;
}
- // Edit mode: render editable text parts. Interim skill cards are a
- // mid-stream concern, not relevant in edit mode.
+ // Interim skill cards are a mid-stream concern, not relevant in edit mode.
if (edit === true && enterEdit && setSiblingIdx) {
return (
- <>
- {(content ?? []).map((part, localIdx) => {
- if (!part) {
- return null;
- }
- const idx = absoluteIndexAt(localIdx);
- const isTextPart =
- part?.type === ContentTypes.TEXT ||
- typeof (part as unknown as Agents.MessageContentText)?.text === 'string';
- const isThinkPart =
- part?.type === ContentTypes.THINK ||
- typeof (part as unknown as Agents.ReasoningDeltaUpdate)?.think === 'string';
- if (!isTextPart && !isThinkPart) {
- return null;
- }
-
- const isToolCall = part.type === ContentTypes.TOOL_CALL || part['tool_call_ids'] != null;
- if (isToolCall) {
- return null;
- }
-
- return (
-
- );
- })}
- >
+
+
+
+ renderPart(part, idx, isLastPart)}
+ />
+
+
);
}
diff --git a/client/src/components/Chat/Messages/Content/EditContentParts.tsx b/client/src/components/Chat/Messages/Content/EditContentParts.tsx
new file mode 100644
index 0000000000..fd2129aae6
--- /dev/null
+++ b/client/src/components/Chat/Messages/Content/EditContentParts.tsx
@@ -0,0 +1,444 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useRecoilValue } from 'recoil';
+import { ContentTypes } from 'librechat-data-provider';
+import { Alert, Button, TextareaAutosize } from '@librechat/client';
+import { useUpdateMessageContentMutation } from 'librechat-data-provider/react-query';
+import type { TMessageContentParts, TextData } from 'librechat-data-provider';
+import type { ReactNode } from 'react';
+import { useMessagesConversation, useMessagesOperations } from '~/Providers';
+import { splitMarkdownIntoBlocks } from './splitMarkdown';
+import { useGetAddedConvo } from '~/hooks/Chat';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+import store from '~/store';
+
+type EditableType = ContentTypes.TEXT | ContentTypes.THINK;
+
+type EditablePart = {
+ index: number;
+ localIndex: number;
+ type: EditableType;
+ original: string;
+};
+
+type EditContentPartsProps = {
+ content: Array;
+ contentIndexOffset?: number;
+ messageId: string;
+ isSubmitting: boolean;
+ enterEdit: (cancel?: boolean) => void | null | undefined;
+ siblingIdx: number | null;
+ setSiblingIdx: (value: number) => void;
+ renderReadOnlyPart: (part: TMessageContentParts, index: number, isLastPart: boolean) => ReactNode;
+};
+
+/** An editable part holds either a bare string or a `{ value, annotations }` object,
+ * which is how the Assistants thread sync stores a response that carries file
+ * citations. Both the read and the write below go through this, so an edit lands in
+ * the same shape it was read from. */
+const getPartValue = (part: TMessageContentParts): string | TextData => {
+ if (part.type === ContentTypes.TEXT) {
+ return part.text;
+ }
+ if (part.type === ContentTypes.THINK) {
+ return part.think;
+ }
+ return undefined;
+};
+
+const getPartText = (part: TMessageContentParts): string | undefined => {
+ const value = getPartValue(part);
+ return typeof value === 'string' ? value : value?.value;
+};
+
+const withPartText = (part: TMessageContentParts, text: string): string | TextData => {
+ const value = getPartValue(part);
+ return value != null && typeof value === 'object' ? { ...value, value: text } : text;
+};
+
+const containsArtifact = (text: string): boolean => {
+ if (!text.includes('artifact')) {
+ return false;
+ }
+ try {
+ return splitMarkdownIntoBlocks(text).some((block) => block.artifactCount > 0);
+ } catch {
+ return false;
+ }
+};
+
+export default function EditContentParts({
+ content,
+ contentIndexOffset = 0,
+ messageId,
+ isSubmitting,
+ enterEdit,
+ siblingIdx,
+ setSiblingIdx,
+ renderReadOnlyPart,
+}: EditContentPartsProps) {
+ const localize = useLocalize();
+ const isRTL = useRecoilValue(store.chatDirection).toLowerCase() === 'rtl';
+ const { conversation } = useMessagesConversation();
+ const { ask, getMessages, setMessages } = useMessagesOperations();
+ const getAddedConvo = useGetAddedConvo();
+ const firstEditorRef = useRef(null);
+ const [saveError, setSaveError] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const updateMessageContentMutation = useUpdateMessageContentMutation(
+ conversation?.conversationId ?? '',
+ );
+
+ const editableParts = useMemo(() => {
+ const result: EditablePart[] = [];
+ content.forEach((part, localIndex) => {
+ if (!part || (part.type !== ContentTypes.TEXT && part.type !== ContentTypes.THINK)) {
+ return;
+ }
+ if (part.type === ContentTypes.TEXT && part.tool_call_ids != null) {
+ return;
+ }
+ const original = getPartText(part);
+ if (original == null || containsArtifact(original)) {
+ return;
+ }
+ result.push({
+ index: localIndex + contentIndexOffset,
+ localIndex,
+ type: part.type,
+ original,
+ });
+ });
+ return result;
+ }, [content, contentIndexOffset]);
+
+ const [drafts, setDrafts] = useState>(() =>
+ Object.fromEntries(editableParts.map((part) => [part.index, part.original])),
+ );
+
+ const editableByLocalIndex = useMemo(
+ () => new Map(editableParts.map((part) => [part.localIndex, part])),
+ [editableParts],
+ );
+ const changedParts = useMemo(
+ () => editableParts.filter((part) => drafts[part.index] !== part.original),
+ [drafts, editableParts],
+ );
+ /** Emptying a part would persist a blank one, and nothing in the editor offers a
+ * way back: there is no delete-part affordance, so the only reading of a cleared
+ * box is an accident. The sibling `EditMessage` refuses the same edit through its
+ * form's `required` rule, so both editors hold the same line. */
+ const hasBlankEdit = useMemo(
+ () => changedParts.some((part) => (drafts[part.index] ?? '').trim() === ''),
+ [changedParts, drafts],
+ );
+ const editedMessage = getMessages()?.find((item) => item.messageId === messageId);
+ const rerunRequiresSave = editedMessage?.isCreatedByUser !== true && changedParts.length > 1;
+ const isBusy = isSubmitting || isSaving;
+
+ useEffect(() => {
+ const editor = firstEditorRef.current;
+ if (!editor) {
+ return;
+ }
+ editor.focus();
+ editor.setSelectionRange(editor.value.length, editor.value.length);
+ }, []);
+
+ const applySavedParts = useCallback(
+ (savedParts: EditablePart[]) => {
+ const messages = getMessages();
+ if (!messages || savedParts.length === 0) {
+ return;
+ }
+ const changedByLocalIndex = new Map(
+ savedParts.map((part) => [part.localIndex, { type: part.type, text: drafts[part.index] }]),
+ );
+ setMessages(
+ messages.map((currentMessage) => {
+ if (currentMessage.messageId !== messageId || !Array.isArray(currentMessage.content)) {
+ return currentMessage;
+ }
+ return {
+ ...currentMessage,
+ content: currentMessage.content.map((part, localIndex) => {
+ const change = changedByLocalIndex.get(localIndex);
+ if (!part || !change || part.type !== change.type) {
+ return part;
+ }
+ return {
+ ...part,
+ [change.type]: withPartText(part, change.text),
+ } as TMessageContentParts;
+ }),
+ };
+ }),
+ );
+ },
+ [drafts, getMessages, messageId, setMessages],
+ );
+
+ const saveChanges = useCallback(async () => {
+ if (changedParts.length === 0 || hasBlankEdit || isBusy) {
+ return;
+ }
+ setIsSaving(true);
+ setSaveError(false);
+ /** The endpoint takes one part per call and nothing rolls a write back, so a
+ * refused part leaves the earlier ones on the server. Recording what actually
+ * landed lets the failure reconcile the transcript with the server instead of
+ * claiming nothing was saved, and leaves only the refused parts still edited. */
+ const savedParts: EditablePart[] = [];
+ try {
+ /** Each endpoint call replaces the full content array. Keep writes ordered so a
+ * later edit reads the content produced by the previous one instead of racing it. */
+ for (const part of changedParts) {
+ await updateMessageContentMutation.mutateAsync({
+ index: part.index,
+ conversationId: conversation?.conversationId ?? '',
+ text: drafts[part.index],
+ messageId,
+ });
+ savedParts.push(part);
+ }
+ } catch {
+ setSaveError(true);
+ } finally {
+ applySavedParts(savedParts);
+ setIsSaving(false);
+ }
+
+ if (savedParts.length === changedParts.length) {
+ enterEdit(true);
+ }
+ }, [
+ applySavedParts,
+ changedParts,
+ conversation?.conversationId,
+ drafts,
+ enterEdit,
+ hasBlankEdit,
+ isBusy,
+ messageId,
+ updateMessageContentMutation,
+ ]);
+
+ const updateAndRerun = useCallback(() => {
+ const firstChange = changedParts[0];
+ if (!firstChange || !editedMessage || rerunRequiresSave || hasBlankEdit || isBusy) {
+ return;
+ }
+ const messages = getMessages();
+
+ /** `ask` refuses to send while another response is streaming and reports it by
+ * returning false. Closing the editor regardless would throw the drafts away for
+ * a rerun that never started, so a refused send leaves the editor as it was. */
+ let refused = false;
+
+ if (editedMessage.isCreatedByUser === true) {
+ const userText = editableParts
+ .filter((part) => part.type === ContentTypes.TEXT)
+ .map((part) => drafts[part.index])
+ .join('\n');
+ refused =
+ ask(
+ {
+ text: userText,
+ parentMessageId: editedMessage.parentMessageId,
+ conversationId: editedMessage.conversationId,
+ },
+ {
+ overrideFiles: editedMessage.files,
+ overrideManualSkills: editedMessage.manualSkills,
+ overrideQuotes: editedMessage.quotes,
+ addedConvo: getAddedConvo() || undefined,
+ },
+ ) === false;
+ } else {
+ const parentMessage = messages?.find(
+ (item) => item.messageId === editedMessage.parentMessageId,
+ );
+ if (!parentMessage) {
+ return;
+ }
+ const editedContent =
+ firstChange.type === ContentTypes.THINK
+ ? {
+ index: firstChange.index,
+ type: ContentTypes.THINK as const,
+ [ContentTypes.THINK]: drafts[firstChange.index],
+ }
+ : {
+ index: firstChange.index,
+ type: ContentTypes.TEXT as const,
+ [ContentTypes.TEXT]: drafts[firstChange.index],
+ };
+ refused =
+ ask(
+ { ...parentMessage },
+ {
+ editedContent,
+ editedMessageId: messageId,
+ isRegenerate: true,
+ isEdited: true,
+ overrideManualSkills: parentMessage.manualSkills,
+ overrideQuotes: parentMessage.quotes,
+ addedConvo: getAddedConvo() || undefined,
+ },
+ ) === false;
+ }
+
+ if (refused) {
+ return;
+ }
+
+ setSiblingIdx((siblingIdx ?? 0) - 1);
+ enterEdit(true);
+ }, [
+ ask,
+ changedParts,
+ drafts,
+ editedMessage,
+ editableParts,
+ enterEdit,
+ getAddedConvo,
+ getMessages,
+ hasBlankEdit,
+ isBusy,
+ messageId,
+ rerunRequiresSave,
+ setSiblingIdx,
+ siblingIdx,
+ ]);
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ enterEdit(true);
+ return;
+ }
+ if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+ event.preventDefault();
+ updateAndRerun();
+ return;
+ }
+ if (event.key.toLowerCase() === 's' && (event.ctrlKey || event.metaKey)) {
+ event.preventDefault();
+ void saveChanges();
+ }
+ },
+ [enterEdit, saveChanges, updateAndRerun],
+ );
+
+ /** Both states share the footer's status slot so neither can add a row and
+ * shift the message below it. */
+ const getStatusMessage = () => {
+ if (hasBlankEdit) {
+ return localize('com_ui_message_part_empty');
+ }
+ if (rerunRequiresSave) {
+ return localize('com_ui_save_before_rerun');
+ }
+ if (changedParts.length > 0) {
+ return localize('com_ui_unsaved_changes');
+ }
+ return '';
+ };
+
+ return (
+
+ {saveError && {localize('com_ui_save_message_error')}}
+
+
-
- );
-};
-
-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`,
+ );
+ });
+ });
+ }
+}