fix(desktop): preserve agent mention separator after send (#5623)

**Category:** fix
**User Impact:** Typing immediately after sending to a persistently
addressed agent now continues after the agent mention instead of
corrupting it.

**Problem:** Post-send restoration passed the persistent `@Agent `
prefix through the Markdown parser, which discarded its trailing
separator and left WebKit rendering the caret at the mention boundary.

**Solution:** Restore the prefix as literal ProseMirror text, preserve
the separator, and focus a selection placed at the restored document
end. This does not expand or otherwise change the setting’s existing
scope: persistent addressed agents remain thread-only.

<details>
<summary>File changes</summary>

**desktop/src/features/messages/lib/useRichTextEditor.ts**
Adds a focused plain-text restoration helper that preserves trailing
whitespace while suppressing authored-update reconciliation.

**desktop/src/features/messages/ui/useMentionSendFlow.ts**
Routes non-empty post-send persistent audience restoration through the
literal-text helper instead of Markdown content loading.

**desktop/tests/e2e/persistent-agent-audience.spec.ts**
Extends the real Enter-send flow to assert the preserved separator,
document-end selection, and immediate typing outside the agent mention.

</details>

## Reproduction steps

1. Open a thread with a persistently addressed agent.
2. Send a message with Enter.
3. Confirm the composer restores the addressed agent and a trailing
space.
4. Type immediately without clicking the composer.
5. Confirm the new text appears after the agent mention and the mention
remains highlighted.



https://github.com/user-attachments/assets/92f088aa-a516-48d1-acde-35e29f558f14

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-12 10:32:08 -07:00
committed by GitHub
parent 884ed8a5d3
commit a8e5c89e23
3 changed files with 37 additions and 15 deletions

View File

@@ -742,17 +742,26 @@ export function useRichTextEditor({
[editor],
);
const setContentAndFocusEnd = React.useCallback(
(markdown: string) => {
/**
* Replace the editor document with literal plain text and focus its end.
*
* Unlike markdown `setContent`, this preserves trailing whitespace. The
* transaction is marked as programmatic so authored-update observers do not
* reconcile against the intermediate post-send restoration.
*/
const restorePlainTextAndFocusEnd = React.useCallback(
(text: string) => {
if (!editor) return;
// The caller already synchronizes composer state. Keep this programmatic
// restoration out of user-edit observers (autocomplete/reconciliation),
// then move selection in the same command chain.
editor
.chain()
.setContent(markdown, { emitUpdate: false })
.focus("end")
.run();
const paragraph = editor.schema.nodes.paragraph.create(
null,
text ? editor.schema.text(text) : undefined,
);
const tr = editor.state.tr
.replaceWith(0, editor.state.doc.content.size, paragraph)
.setMeta("preventUpdate", true);
tr.setSelection(TextSelection.atEnd(tr.doc));
editor.view.dispatch(tr);
editor.view.focus();
},
[editor],
);
@@ -950,7 +959,7 @@ export function useRichTextEditor({
isEmpty,
clearContent,
setContent,
setContentAndFocusEnd,
restorePlainTextAndFocusEnd,
focus,
focusEnd,
focusPreserve,

View File

@@ -73,7 +73,7 @@ type UseMentionSendFlowOptions = {
>;
richText: Pick<
UseRichTextEditorResult,
"clearContent" | "setContent" | "setContentAndFocusEnd"
"clearContent" | "setContent" | "restorePlainTextAndFocusEnd"
>;
setContent: (content: string) => void;
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -334,7 +334,7 @@ export function useMentionSendFlow({
setContent(postSendContent);
contentRef.current = postSendContent;
if (postSendContent) {
richText.setContentAndFocusEnd(postSendContent);
richText.restorePlainTextAndFocusEnd(postSendContent);
mentions.cancelMentionAutocomplete();
} else richText.clearContent();
setPendingImeta([]);
@@ -352,7 +352,7 @@ export function useMentionSendFlow({
mentions.cancelMentionAutocomplete,
mentions.clearMentions,
richText.clearContent,
richText.setContentAndFocusEnd,
richText.restorePlainTextAndFocusEnd,
setContent,
setIsEmojiPickerOpen,
setPendingImeta,

View File

@@ -179,11 +179,24 @@ test("persistent agents transition atomically before Enter-send resolves", async
// to ProseMirror coordinates proves selection.from/to === doc.content.size.
return {
empty: selection.isCollapsed,
text: element.textContent,
endsWithSpace: element.textContent?.endsWith(" ") ?? false,
atDocumentEnd: position + 1 === viewDesc.size - 2,
};
}),
)
.toEqual({ empty: true, atDocumentEnd: true });
.toEqual({
empty: true,
text: "@Morgarita ",
endsWithSpace: true,
atDocumentEnd: true,
});
// Exercise the reported contract, not just its selection prerequisites:
// immediate typing after restoration must remain outside the mention chip.
await input.pressSequentially("testing");
await expect(input).toHaveText("@Morgarita testing");
await expect(input.locator(".agent-mention-highlight")).toHaveCount(1);
});
test("timeline agent send remains one-shot and returns to the placeholder", async ({