feat: decide the AI edit's No change signal from the value the composer will actually hold rather than from the model's answer, since a rewrite whose every added character falls past the body-length cap leaves the textarea exactly as it was and reporting Rewritten over an unchanged body is the one thing that signal exists to prevent

This commit is contained in:
Matthew Meszaros
2026-09-11 22:30:32 -07:00
parent ea321ebefb
commit 4bf412c226
2 changed files with 35 additions and 4 deletions
+6 -2
View File
@@ -241,10 +241,15 @@ export default function TextareaAIEdit({
// go back on before anything is written or compared: what lands in
// the box IS what "did it change?" is answered from (issue #432).
const applied = restoreEdges(target.text, text);
setChanged(applied !== target.text);
const prefix = prevValue.slice(0, target.start);
const suffix = prevValue.slice(target.end);
const cap = (s: string) => (maxLen ? s.slice(0, maxLen) : s);
// Against the value that will actually be in the box, not against
// the model's answer: a rewrite that falls past the composer's cap
// leaves the body exactly as it was, and "Rewritten" over an
// unchanged body is the thing this signal exists to prevent.
const settled = cap(prefix + applied + suffix);
setChanged(settled !== prevValue);
typewriter.run(
applied,
(partial) => {
@@ -263,7 +268,6 @@ export default function TextareaAIEdit({
// What the box holds, not what the model sent: maxLen can
// cut the tail off, and a range recorded past the end would
// have Undo and Again working on text that is not there.
const settled = cap(prefix + applied + suffix);
const end = Math.min(target.start + applied.length, settled.length);
const newRange = { start: target.start, end, text: settled.slice(target.start, end) };
lastRun.current = { instruction, prevValue, start: target.start, origEnd: target.end };
@@ -44,13 +44,13 @@ import TextareaAIEdit from "./TextareaAIEdit";
const MAX = 20;
function Host({ initial }: { initial: string }) {
function Host({ initial, max = MAX }: { initial: string; max?: number }) {
const ref = React.useRef<HTMLTextAreaElement>(null);
const [value, setValue] = React.useState(initial);
return (
<>
<textarea ref={ref} value={value} onChange={(e) => setValue(e.target.value)} />
<TextareaAIEdit textareaRef={ref} value={value} onChange={setValue} maxLen={MAX} />
<TextareaAIEdit textareaRef={ref} value={value} onChange={setValue} maxLen={max} />
</>
);
}
@@ -102,6 +102,33 @@ describe("TextareaAIEdit against the composer's length cap", () => {
expect(sent?.text).toBe("two");
});
it("says No change when the cap swallows the rewrite whole", async () => {
const { container } = render(
<QueryClientProvider client={new QueryClient()}>
<Host initial="abcdefg" max={7} />
</QueryClientProvider>,
);
const ta = container.querySelector("textarea")!;
await act(async () => {
ta.focus();
ta.setSelectionRange(5, 7);
document.dispatchEvent(new Event("selectionchange"));
});
// Everything this adds lands past the cap, so the body cannot move.
reply = { text: "fg extra", credits_charged: 1, tokens_used: 10 };
await act(async () => {
fireEvent.mouseDown(await screen.findByText("Edit with AI"));
});
const input = await screen.findByPlaceholderText("Tell AI how to change it…");
await act(async () => {
fireEvent.change(input, { target: { value: "expand" } });
fireEvent.keyDown(input, { key: "Enter" });
});
expect(ta.value).toBe("abcdefg");
await screen.findByText("No change");
expect(screen.queryByText("Rewritten")).toBeNull();
});
it("leaves Undo pointing at the words that were selected, not a derived range", async () => {
const initial = "one two three";
const { container } = render(