From ea321ebefb8631c736b9b2aba008b8f64a9b1b8d Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 11 Sep 2026 22:22:55 -0700 Subject: [PATCH] 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 --- web/src/components/app/ai/TextareaAIEdit.tsx | 27 +++++++++++++++---- .../components/app/ai/textareaAIEdit.test.tsx | 17 +++++++++++- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/web/src/components/app/ai/TextareaAIEdit.tsx b/web/src/components/app/ai/TextareaAIEdit.tsx index bd19dc41..3908d639 100644 --- a/web/src/components/app/ai/TextareaAIEdit.tsx +++ b/web/src/components/app/ai/TextareaAIEdit.tsx @@ -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(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; diff --git a/web/src/components/app/ai/textareaAIEdit.test.tsx b/web/src/components/app/ai/textareaAIEdit.test.tsx index 3425957d..fe22ceee 100644 --- a/web/src/components/app/ai/textareaAIEdit.test.tsx +++ b/web/src/components/app/ai/textareaAIEdit.test.tsx @@ -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( @@ -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"); }); });