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"); }); });