prompt history only takes the arrow key from an empty composer

Up replaced what you were typing. The guard was a caret-position test — Up only recalls from the first line
— which is correct for a multi-line draft and useless for a one-line one: the caret IS on the first line, so
every check passed and a half-written thought was swapped for something from last week. A draft is not
recoverable from anywhere and the history is, so the tie goes to the draft.

"Only when empty" alone would have made the feature useless, though, and that is the part worth writing
down: once a recalled prompt is in the box, the box is not empty, so an emptiness test allows exactly one
step back and then refuses. usePromptHistory already knew the difference — indexRef null means composing —
it just never told anyone. It now exposes isRecalling(), so the rule is "no text OF YOURS" rather than "no
text".

Whitespace counts as empty: a composer holding two spaces is not a draft anybody would miss, and an
invisible character silently blocking Up is worse than losing it. A draft that merely STARTS with
whitespace is still protected.

The line tests stay on top of it — in a recalled prompt spanning three lines, Up from the middle moves the
caret, because there is a line above to go to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 04:03:03 +00:00
co-authored by Claude Opus 5
parent a5f387929d
commit 02df4ac61c
3 changed files with 64 additions and 5 deletions
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
/*
The rule, as a pure function, because the interesting part is not the DOM.
History must never clobber text the person typed, and must still be walkable once a recalled prompt is
sitting in the box. Those two pull in opposite directions and the second is what a naive "only when empty"
check breaks.
*/
const mayRecall = (value: string, recalling: boolean): boolean => value.trim() === '' || recalling;
describe('when history may take the arrow key', () => {
it('takes it on an empty composer', () => {
expect(mayRecall('', false)).toBe(true);
});
// The bug this rule exists for: the caret is on the first line of a one-line draft, so every position
// test passes, and Up used to replace what you were writing.
it('leaves a draft alone', () => {
expect(mayRecall('half a thought', false)).toBe(false);
});
// Without this, you could step back exactly once and then be refused, because the recalled prompt is
// itself text in the box.
it('keeps walking once it is in the history', () => {
expect(mayRecall('a prompt from earlier', true)).toBe(true);
});
it('treats whitespace as empty', () => {
expect(mayRecall(' ', false)).toBe(true);
expect(mayRecall('\n\t ', false)).toBe(true);
});
it('protects a draft that merely starts with whitespace', () => {
expect(mayRecall(' still typing', false)).toBe(false);
});
});
@@ -389,15 +389,26 @@ export function useEmbeddableChat(params: UseEmbeddableChatParams, onMessageComp
/**
* Recall a previous prompt, the way a shell does.
*
* Up only reaches the history from the FIRST line and Down only from the last, so in a multi-line draft
* the arrows still move the caret — there is a line above to go to, and taking the key would strand the
* cursor. On the first line there is nowhere up to go, which is exactly when history is what you meant.
* `step` returning null (empty history, or already at the oldest) leaves the key alone too.
* **Only from an empty composer.** The caret-on-the-first-line test below is not enough on its own, and
* that was the bug: with a one-line draft you were typing, the caret IS on the first line, so Up replaced
* what you had written with something from last week. A draft is not recoverable from anywhere, and
* history is — so the tie goes to the draft, always.
*
* `isRecalling` is what keeps the rule from making the feature useless. Once a recalled prompt is in the
* box the box is not empty, so a plain emptiness test would allow one step back and then refuse; walking
* further is still allowed because that text came from the history rather than from you.
*
* Whitespace counts as empty. A composer holding two spaces is not a draft anybody would miss, and an
* invisible character silently blocking Up is worse than losing it.
*
* The line tests stay on top of that: in a recalled prompt spanning three lines, Up from the middle moves
* the caret, because there is a line above to go to and taking the key would strand the cursor.
*/
const recallPrompt = (ev: KeyboardEvent<HTMLTextAreaElement>, direction: -1 | 1): void => {
const field = ev.currentTarget;
const { selectionStart, selectionEnd, value } = field;
if (selectionStart !== selectionEnd) return; // a selection means the arrows are extending/collapsing it
if (value.trim() !== '' && !history.isRecalling()) return;
const onEdge =
direction === -1 ? !value.slice(0, selectionStart).includes('\n') : !value.slice(selectionEnd).includes('\n');
if (!onEdge) return;
@@ -25,6 +25,15 @@ export type PromptHistory = {
* whatever it normally does.
*/
step: (direction: -1 | 1, current: string) => string | null;
/**
* Are we currently walking the history, rather than composing?
*
* The caller needs this to answer "may Up take this key" without also making the history unusable. The
* rule is that history must never clobber text the PERSON typed — but once a recalled prompt is in the
* box, the box is not empty any more, and a plain emptiness test would allow exactly one step back and
* then refuse. This is the difference between "there is text" and "there is text of yours".
*/
isRecalling: () => boolean;
};
export function usePromptHistory(): PromptHistory {
@@ -87,5 +96,7 @@ export function usePromptHistory(): PromptHistory {
return list[at - 1] ?? null;
}, []);
return { record, step };
const isRecalling = useCallback(() => indexRef.current !== null, []);
return { record, step, isRecalling };
}