feat: put the AI edit's review selection back after React commits the value rather than before it, since React writes the textarea's value during commit and that write moves the cursor to the end, leaving the rewrite unselected and Undo's restored range never reaching the DOM, and strengthen the Undo test to assert the range a follow-up run targets instead of the restored value, which Undo sets regardless and which therefore pinned none of the behaviour the commit before it fixed

This commit is contained in:
Matthew Meszaros
2026-09-11 22:22:55 -07:00
parent af7256ac85
commit ea321ebefb
2 changed files with 38 additions and 6 deletions
+22 -5
View File
@@ -85,6 +85,12 @@ export default function TextareaAIEdit({
} | null>(null);
// Value we last wrote ourselves; external edits while open close the UI.
const expectedValue = React.useRef<string | null>(null);
// A selection to put back once the value it belongs to is on screen. React
// writes the textarea's value during commit, after our handler returns, and
// that write moves the cursor to the end: setting the range inline is
// undone a moment later. It is set both ways, because a restore to the
// value already showing re-renders nothing for the effect to run on.
const pendingSelection = React.useRef<{ start: number; end: number } | null>(null);
const openRef = React.useRef(open);
openRef.current = open;
@@ -101,6 +107,17 @@ export default function TextareaAIEdit({
expectedValue.current = null;
}, []);
React.useLayoutEffect(() => {
const range = pendingSelection.current;
if (!range) return;
pendingSelection.current = null;
const ta = textareaRef.current;
if (!ta) return;
ta.setSelectionRange(range.start, range.end);
setRect(textareaRangeRect(ta, range.start, range.end));
setHighlights(textareaRangeRects(ta, range.start, range.end));
}, [value, textareaRef]);
// Re-measures the anchor rect and the painted selection for a range.
const syncRects = React.useCallback(
(target: Selection, paint: boolean) => {
@@ -252,11 +269,10 @@ export default function TextareaAIEdit({
lastRun.current = { instruction, prevValue, start: target.start, origEnd: target.end };
frozen.current = newRange;
const ta = textareaRef.current;
if (ta) {
// Leave the rewrite selected so it reads as "this changed"
// and a follow-up edit can chain on it.
ta.setSelectionRange(newRange.start, newRange.end);
}
// Leave the rewrite selected so it reads as "this changed"
// and a follow-up edit can chain on it.
pendingSelection.current = { start: newRange.start, end: newRange.end };
if (ta) ta.setSelectionRange(newRange.start, newRange.end);
syncRects(newRange, true);
setPhase("applied");
},
@@ -317,6 +333,7 @@ export default function TextareaAIEdit({
text: last.prevValue.slice(last.start, last.origEnd),
};
frozen.current = restored;
pendingSelection.current = { start: last.start, end: last.origEnd };
if (ta) ta.setSelectionRange(last.start, last.origEnd);
syncRects(restored, true);
lastRun.current = null;
@@ -90,6 +90,10 @@ describe("TextareaAIEdit against the composer's length cap", () => {
expect(ta.value).toBe("one TWO IS MUCH LONG");
expect(ta.value.length).toBe(MAX);
// The rewrite is left selected, which only holds if the range survives
// React's own write of the value.
expect([ta.selectionStart, ta.selectionEnd]).toEqual([4, MAX]);
// …and Again still re-sends the words that were selected, rather than a
// range worked back out of the lengths the truncation invalidated.
await act(async () => {
@@ -98,7 +102,7 @@ describe("TextareaAIEdit against the composer's length cap", () => {
expect(sent?.text).toBe("two");
});
it("undoes back to exactly the body that was there", async () => {
it("leaves Undo pointing at the words that were selected, not a derived range", async () => {
const initial = "one two three";
const { container } = render(
<QueryClientProvider client={new QueryClient()}>
@@ -124,5 +128,16 @@ describe("TextareaAIEdit against the composer's length cap", () => {
fireEvent.click(screen.getByText("Undo"));
});
expect(ta.value).toBe(initial);
expect([ta.selectionStart, ta.selectionEnd]).toEqual([4, 7]);
// Undo restores the body whatever the range says, so what it leaves
// behind is what has to be checked: the next run must target the words
// that were selected, not a range the truncation invalidated.
const again = await screen.findByPlaceholderText("Tell AI how to change it…");
await act(async () => {
fireEvent.change(again, { target: { value: "try once more" } });
fireEvent.keyDown(again, { key: "Enter" });
});
expect(sent?.text).toBe("two");
});
});