feat(chat): persist more composer input history with global or session scope; recallable with up/down arrow keys (#3035)
* feat(chat): persist input history * feat(settings): configure input history scope * fix(web): keep input history validation packaged * fix(chat): preserve input history across tabs * fix(settings): restore prompt history limit * fix(settings): keep history deletion warning visible * fix(i18n): restore Turkish Git empty state translations --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
29480383cc
commit
1d6b15bc04
@@ -3,7 +3,7 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
||||
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedContextPart, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { isServerOwnedMessageQueue, createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedContextPart, type QueuedMessage } from '@/stores/messageQueueStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import * as sessionActions from '@/sync/session-actions';
|
||||
import { buildLinkedIssue, buildLinkedLinearIssue } from '@/lib/linkedIssues';
|
||||
import { useUserMessageHistory } from "@/sync/sync-context";
|
||||
import { getInlineCommentDraftKey, useInlineCommentDraftStore, type InlineCommentDraft, type InlineCommentDraftTarget } from '@/stores/useInlineCommentDraftStore';
|
||||
import { useSnippetsStore } from '@/stores/useSnippetsStore';
|
||||
import { renderMagicPrompt } from '@/lib/magicPrompts';
|
||||
@@ -166,6 +165,17 @@ import { LinkedReferenceRow } from './composer/ui/LinkedReferenceRow';
|
||||
import { RevertedMessageDock } from './composer/ui/RevertedMessageDock';
|
||||
import { SessionSuggestionChip } from '@/components/chat/SessionSuggestionChip';
|
||||
import { SessionGoalRow } from '@/components/chat/SessionGoalRow';
|
||||
import {
|
||||
createInputHistoryIdentity,
|
||||
selectInputHistoryEntries,
|
||||
type InputHistorySubmission,
|
||||
useInputHistoryStore,
|
||||
} from '@/stores/useInputHistoryStore';
|
||||
import {
|
||||
buildChatInputHistorySubmissions,
|
||||
buildInputHistoryNavigatorIdentity,
|
||||
mapInputHistoryEntriesToValues,
|
||||
} from './inputHistory';
|
||||
|
||||
// Lazy like in ChatMessage: a static import would pull the @pierre/diffs and
|
||||
// Shiki stacks into the eager startup graph for a dialog opened on demand.
|
||||
@@ -188,6 +198,7 @@ const MAX_MOBILE_COMPOSER_LINES = 16;
|
||||
*/
|
||||
const MOBILE_COMPOSER_BOUND_GAP_PX = 4;
|
||||
const EMPTY_QUEUE: QueuedMessage[] = [];
|
||||
const EMPTY_INPUT_HISTORY_ENTRIES = Object.freeze([] as const);
|
||||
const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560;
|
||||
const renameFileForAttachmentCitation = (file: File, filename: string): File => {
|
||||
if (file.name === filename) {
|
||||
@@ -888,9 +899,28 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts);
|
||||
const hasDrafts = draftCount > 0;
|
||||
|
||||
// User message history for up/down arrow navigation.
|
||||
// Keep this on a narrow hook instead of full session message records.
|
||||
const messageHistory = useMessageHistory(useUserMessageHistory(currentSessionId ?? ""));
|
||||
const inputHistoryScope = useInputHistoryStore((state) => state.scope);
|
||||
const inputHistoryIdentity = React.useMemo(
|
||||
() => createInputHistoryIdentity(
|
||||
activeRuntimeKey,
|
||||
currentSessionDirectoryForSync ?? currentDirectory ?? '',
|
||||
currentSessionId ?? 'draft',
|
||||
),
|
||||
[activeRuntimeKey, currentDirectory, currentSessionDirectoryForSync, currentSessionId],
|
||||
);
|
||||
const inputHistoryEntries = useInputHistoryStore(React.useCallback((state) => {
|
||||
const entries = selectInputHistoryEntries(state, inputHistoryIdentity);
|
||||
return entries.length === 0 ? EMPTY_INPUT_HISTORY_ENTRIES : entries;
|
||||
}, [inputHistoryIdentity]));
|
||||
const historyValues = React.useMemo(
|
||||
() => mapInputHistoryEntriesToValues(inputHistoryEntries),
|
||||
[inputHistoryEntries],
|
||||
);
|
||||
const messageHistoryIdentity = React.useMemo(
|
||||
() => buildInputHistoryNavigatorIdentity(inputHistoryScope, inputHistoryIdentity),
|
||||
[inputHistoryIdentity, inputHistoryScope],
|
||||
);
|
||||
const messageHistory = useMessageHistory<AttachedFile>(historyValues, messageHistoryIdentity);
|
||||
|
||||
// Keep messageRef in sync with message state
|
||||
React.useEffect(() => {
|
||||
@@ -1391,6 +1421,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
|
||||
historySubmissions?: InputHistorySubmission[];
|
||||
delivery?: 'steer';
|
||||
} | undefined;
|
||||
if (isBtwActive && btwSessionId && btwDirectory) {
|
||||
@@ -1439,6 +1470,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
if (queuedOnly && queuedMessagesToSend.length === 0) return;
|
||||
}
|
||||
|
||||
const historySubmissions = buildChatInputHistorySubmissions({
|
||||
inputMode,
|
||||
// Server-owned items were recorded on acceptance. VS Code records
|
||||
// the full items actually taken, never the metadata projection.
|
||||
queuedMessages: isServerOwnedMessageQueue() ? [] : queuedMessagesToSend,
|
||||
composerText: inputSnapshot.message,
|
||||
composerAttachments: attachedFiles,
|
||||
includeComposer: !queuedOnly && inputSnapshot.hasContent,
|
||||
});
|
||||
if (historySubmissions?.length) {
|
||||
sendMessageOptions = { ...sendMessageOptions, historySubmissions };
|
||||
}
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took. What is
|
||||
// here belongs to this send: queueing took its own context with it.
|
||||
@@ -1910,9 +1954,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
if (e.key === 'ArrowUp' && canNavigateHistoryUp) {
|
||||
e.preventDefault();
|
||||
const recalled = messageHistory.older(message);
|
||||
const recalled = messageHistory.older({ text: message, attachments: attachedFiles });
|
||||
if (recalled !== null) {
|
||||
setMessage(recalled);
|
||||
setMessage(recalled.text);
|
||||
useInputStore.getState().setAttachedFiles([...recalled.attachments]);
|
||||
// Caret to the start, so the recalled message reads from its
|
||||
// beginning rather than from wherever the draft's caret was.
|
||||
requestAnimationFrame(() => composerRef.current?.setSelection(0, 0));
|
||||
@@ -1922,8 +1967,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
|
||||
if (e.key === 'ArrowDown' && canNavigateHistoryDown) {
|
||||
e.preventDefault();
|
||||
const recalled = messageHistory.newer();
|
||||
if (recalled !== null) setMessage(recalled);
|
||||
const recalled = messageHistory.newer({ text: message, attachments: attachedFiles });
|
||||
if (recalled !== null) {
|
||||
setMessage(recalled.text);
|
||||
useInputStore.getState().setAttachedFiles([...recalled.attachments]);
|
||||
requestAnimationFrame(() => composerRef.current?.setSelection(recalled.text.length, recalled.text.length));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ existing mobile fixed-position rules unchanged.
|
||||
|---|---|
|
||||
| `language/` | What the text *means*: `@` references, `/` and `#` tokens, markdown, and which picker a caret asks for |
|
||||
| `editor/` | The CodeMirror view that renders the language and owns the caret |
|
||||
| `state/` | Composer state with a lifecycle: drafts, mobile shell, history, popup placement, draft targeting |
|
||||
| `state/` | Composer-local lifecycle state: ArrowUp/ArrowDown browsing, draft stash/restore, mobile shell, popup placement, draft targeting |
|
||||
| `submit/` | Turning what the user has into what gets sent |
|
||||
| `attachments/` | Files: paths, drop payloads |
|
||||
| `ui/` | Presentation |
|
||||
@@ -192,6 +192,25 @@ and the send path reading the same grammar.
|
||||
state and registers its application shortcuts locally. The selectors only
|
||||
consume their shared prefix while the draft target UI is mounted.
|
||||
|
||||
## Input recall ownership
|
||||
|
||||
Prompt recall has two owners on purpose.
|
||||
|
||||
- `packages/ui/src/stores/useInputHistoryStore.ts` owns the persisted source of
|
||||
truth. It keeps the runtime-scoped global bucket and the runtime + directory
|
||||
+ session bucket, each capped by the configurable input-history limit. That
|
||||
setting defaults to 40 entries.
|
||||
- `state/useMessageHistory.ts` owns only keyboard traversal through whichever
|
||||
bucket the composer was given. It stashes the current draft on entry and
|
||||
restores it on the way back out.
|
||||
- `ChatInput.tsx` owns the recalled-entry presentation. If the user edits a
|
||||
recalled prompt, the UI may show an overlay state for "this came from
|
||||
history", but that edit does not rewrite stored history.
|
||||
|
||||
Transcript visibility is not part of this contract anymore. Revert markers may
|
||||
hide older user messages from the chat timeline, but they do not decide what
|
||||
ArrowUp and ArrowDown can recall.
|
||||
|
||||
## Mobile
|
||||
|
||||
`state/useMobileComposerShell.ts` and `state/useMobileViewportPin.ts` are
|
||||
@@ -209,12 +228,14 @@ hardware.
|
||||
|
||||
The package has no DOM test environment, so coverage stops at the state and
|
||||
logic layers: the language, the submit assembly, path and drop handling, text
|
||||
splicing, large-paste detection, paste-offer invalidation, message history, and
|
||||
the CodeMirror language extension at the `EditorState` level.
|
||||
splicing, large-paste detection, paste-offer invalidation, input-history
|
||||
traversal, and the CodeMirror language extension at the `EditorState` level.
|
||||
|
||||
Rendering, focus, keyboard behavior, IME and WKWebView are **not covered by
|
||||
tests** and are verified by hand. Do not report a change to them as validated
|
||||
on the strength of type-check and unit tests.
|
||||
tests** and are verified by hand. That includes ArrowUp and ArrowDown recall,
|
||||
caret placement after recall, restored drafts, and any edited-entry overlay.
|
||||
Do not report a change to them as validated on the strength of type-check and
|
||||
unit tests.
|
||||
|
||||
Run tests per file (`bun test <path>`): `mock.module` is process-global, so
|
||||
suites that install module mocks are order-dependent.
|
||||
|
||||
+377
-82
@@ -1,113 +1,408 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import {
|
||||
HISTORY_IDLE,
|
||||
INITIAL_HISTORY_STATE,
|
||||
createHistoryState,
|
||||
resetHistoryState,
|
||||
stepNewer,
|
||||
stepOlder,
|
||||
syncHistoryState,
|
||||
type HistoryState,
|
||||
type MessageHistory,
|
||||
type MessageHistoryValue,
|
||||
useMessageHistory,
|
||||
} from '../useMessageHistory';
|
||||
|
||||
const HISTORY = ['newest', 'middle', 'oldest'];
|
||||
type Attachment = { id: string };
|
||||
|
||||
/** Apply a sequence of steps, returning the texts shown and the final state. */
|
||||
function walk(
|
||||
steps: Array<{ dir: 'older' | 'newer'; draft?: string }>,
|
||||
history: readonly string[] = HISTORY,
|
||||
) {
|
||||
let state: HistoryState = INITIAL_HISTORY_STATE;
|
||||
const texts: Array<string | null> = [];
|
||||
for (const step of steps) {
|
||||
const result = step.dir === 'older'
|
||||
? stepOlder(state, history, step.draft ?? '')
|
||||
: stepNewer(state, history);
|
||||
state = result.state;
|
||||
texts.push(result.text);
|
||||
}
|
||||
return { texts, state };
|
||||
const draft = (text: string, attachments: readonly Attachment[] = []): MessageHistoryValue<Attachment> => ({
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
|
||||
const HISTORY = [
|
||||
draft('oldest'),
|
||||
draft('middle'),
|
||||
draft('newest'),
|
||||
] as const;
|
||||
|
||||
function createState(history: readonly MessageHistoryValue<Attachment>[] = HISTORY, identity = 'session-a') {
|
||||
return createHistoryState(history, identity);
|
||||
}
|
||||
|
||||
describe('walking back', () => {
|
||||
test('the first step recalls the most recent message', () => {
|
||||
expect(walk([{ dir: 'older', draft: 'my draft' }]).texts).toEqual(['newest']);
|
||||
function older(
|
||||
state: HistoryState<Attachment>,
|
||||
currentValue: MessageHistoryValue<Attachment>,
|
||||
history: readonly MessageHistoryValue<Attachment>[] = HISTORY,
|
||||
) {
|
||||
return stepOlder(state, history, currentValue);
|
||||
}
|
||||
|
||||
function newer(
|
||||
state: HistoryState<Attachment>,
|
||||
currentValue: MessageHistoryValue<Attachment>,
|
||||
history: readonly MessageHistoryValue<Attachment>[] = HISTORY,
|
||||
) {
|
||||
return stepNewer(state, history, currentValue);
|
||||
}
|
||||
|
||||
type MinimalDocument = {
|
||||
nodeType: 9;
|
||||
defaultView: typeof globalThis;
|
||||
activeElement: null;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
documentElement?: MinimalContainer;
|
||||
body?: MinimalContainer;
|
||||
};
|
||||
|
||||
type MinimalContainer = {
|
||||
nodeType: 1;
|
||||
tagName: 'DIV';
|
||||
nodeName: 'DIV';
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml';
|
||||
ownerDocument: MinimalDocument;
|
||||
addEventListener: () => void;
|
||||
removeEventListener: () => void;
|
||||
};
|
||||
|
||||
type MessageHistoryHookResult = {
|
||||
current: MessageHistory<Attachment> | null;
|
||||
};
|
||||
|
||||
function installMinimalDom() {
|
||||
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||
const setGlobal = <T,>(name: string, value: T) => {
|
||||
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ElementStub {}
|
||||
const documentStub: MinimalDocument = {
|
||||
nodeType: 9,
|
||||
defaultView: globalThis,
|
||||
activeElement: null,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
const container: MinimalContainer = {
|
||||
nodeType: 1,
|
||||
tagName: 'DIV',
|
||||
nodeName: 'DIV',
|
||||
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||
ownerDocument: documentStub,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
};
|
||||
documentStub.documentElement = container;
|
||||
documentStub.body = container;
|
||||
setGlobal('document', documentStub);
|
||||
setGlobal('window', globalThis);
|
||||
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
|
||||
setGlobal('Element', ElementStub);
|
||||
setGlobal('HTMLElement', ElementStub);
|
||||
setGlobal('HTMLIFrameElement', ElementStub);
|
||||
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
return {
|
||||
// SAFETY: the hook probe renders `null`; React only needs a stable root-like container shape here.
|
||||
container: container as Element & MinimalContainer,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderMessageHistory(
|
||||
history: readonly MessageHistoryValue<Attachment>[],
|
||||
identity: string,
|
||||
) {
|
||||
const dom = installMinimalDom();
|
||||
const root: Root = createRoot(dom.container);
|
||||
const result: MessageHistoryHookResult = { current: null };
|
||||
|
||||
const Probe: React.FC<{ history: readonly MessageHistoryValue<Attachment>[]; identity: string }> = ({ history, identity }) => {
|
||||
result.current = useMessageHistory(history, identity);
|
||||
return null;
|
||||
};
|
||||
|
||||
const render = (nextHistory: readonly MessageHistoryValue<Attachment>[], nextIdentity: string) => {
|
||||
act(() => {
|
||||
root.render(React.createElement(Probe, { history: nextHistory, identity: nextIdentity }));
|
||||
});
|
||||
};
|
||||
|
||||
render(history, identity);
|
||||
|
||||
return {
|
||||
current() {
|
||||
if (!result.current) throw new Error('message history hook did not render');
|
||||
return result.current;
|
||||
},
|
||||
rerender(nextHistory: readonly MessageHistoryValue<Attachment>[], nextIdentity: string) {
|
||||
render(nextHistory, nextIdentity);
|
||||
},
|
||||
teardown() {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
dom.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('message history cursor model', () => {
|
||||
test('uses a 0..history.length cursor with the draft at the endpoint', () => {
|
||||
const state = createState();
|
||||
expect(state.cursor).toBe(HISTORY.length);
|
||||
|
||||
const newest = older(state, draft('half-written prompt'));
|
||||
expect(newest.value).toEqual(draft('newest'));
|
||||
expect(newest.state.cursor).toBe(HISTORY.length - 1);
|
||||
|
||||
const middle = older(newest.state, draft('newest'));
|
||||
expect(middle.value).toEqual(draft('middle'));
|
||||
expect(middle.state.cursor).toBe(HISTORY.length - 2);
|
||||
|
||||
const oldest = older(middle.state, draft('middle'));
|
||||
expect(oldest.value).toEqual(draft('oldest'));
|
||||
expect(oldest.state.cursor).toBe(0);
|
||||
});
|
||||
|
||||
test('successive steps go further back', () => {
|
||||
expect(walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }]).texts)
|
||||
.toEqual(['newest', 'middle', 'oldest']);
|
||||
test('clamps at both ends', () => {
|
||||
const state = createState();
|
||||
expect(newer(state, draft('draft')).value).toBeNull();
|
||||
|
||||
const first = older(state, draft('draft'));
|
||||
const second = older(first.state, draft('newest'));
|
||||
const third = older(second.state, draft('middle'));
|
||||
const clamped = older(third.state, draft('oldest'));
|
||||
|
||||
expect(clamped.value).toBeNull();
|
||||
expect(clamped.state.cursor).toBe(0);
|
||||
});
|
||||
|
||||
test('the oldest message is the end of the line', () => {
|
||||
const { texts } = walk([
|
||||
{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' },
|
||||
]);
|
||||
expect(texts[3]).toBeNull();
|
||||
});
|
||||
test('restores an empty draft when the user returns to the endpoint', () => {
|
||||
const state = createState();
|
||||
const recalled = older(state, draft(''));
|
||||
const restored = newer(recalled.state, draft('newest'));
|
||||
|
||||
test('reaching the end leaves the state where it was', () => {
|
||||
const { state } = walk([
|
||||
{ dir: 'older' }, { dir: 'older' }, { dir: 'older' }, { dir: 'older' },
|
||||
]);
|
||||
expect(state.index).toBe(2);
|
||||
});
|
||||
|
||||
test('empty history recalls nothing', () => {
|
||||
const { texts, state } = walk([{ dir: 'older', draft: 'x' }], []);
|
||||
expect(texts).toEqual([null]);
|
||||
expect(state.index).toBe(HISTORY_IDLE);
|
||||
});
|
||||
|
||||
test('a single-message history has exactly one step', () => {
|
||||
const { texts } = walk([{ dir: 'older' }, { dir: 'older' }], ['only']);
|
||||
expect(texts).toEqual(['only', null]);
|
||||
expect(restored.value).toEqual(draft(''));
|
||||
expect(restored.state.cursor).toBe(HISTORY.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coming back', () => {
|
||||
test('returns toward newer messages', () => {
|
||||
const { texts } = walk([{ dir: 'older' }, { dir: 'older' }, { dir: 'newer' }]);
|
||||
expect(texts[2]).toBe('newest');
|
||||
describe('message history overlays', () => {
|
||||
test('restores edited text for recalled entries and the original draft', () => {
|
||||
const state = createState();
|
||||
|
||||
const recalledNewest = older(state, draft('original draft'));
|
||||
const recalledMiddle = older(recalledNewest.state, draft('edited newest'));
|
||||
const backToNewest = newer(recalledMiddle.state, draft('middle'));
|
||||
const backToDraft = newer(backToNewest.state, draft('edited newest'));
|
||||
|
||||
expect(backToNewest.value).toEqual(draft('edited newest'));
|
||||
expect(backToDraft.value).toEqual(draft('original draft'));
|
||||
});
|
||||
|
||||
test('stepping past the newest restores the stashed draft', () => {
|
||||
const { texts, state } = walk([
|
||||
{ dir: 'older', draft: 'half-written prompt' },
|
||||
{ dir: 'newer' },
|
||||
]);
|
||||
expect(texts[1]).toBe('half-written prompt');
|
||||
expect(state.index).toBe(HISTORY_IDLE);
|
||||
test('restores edited attachments for recalled entries and the draft endpoint', () => {
|
||||
const draftAttachment = { id: 'draft-file' };
|
||||
const recalledAttachment = { id: 'edited-file' };
|
||||
|
||||
const state = createState();
|
||||
const recalledNewest = older(state, draft('draft text', [draftAttachment]));
|
||||
const recalledMiddle = older(recalledNewest.state, draft('newest', [recalledAttachment]));
|
||||
const backToNewest = newer(recalledMiddle.state, draft('middle'));
|
||||
const backToDraft = newer(backToNewest.state, draft('newest', [recalledAttachment]));
|
||||
|
||||
expect(backToNewest.value).toEqual(draft('newest', [recalledAttachment]));
|
||||
expect(backToDraft.value).toEqual(draft('draft text', [draftAttachment]));
|
||||
});
|
||||
|
||||
test('an empty draft is restored as empty rather than left on a message', () => {
|
||||
const { texts } = walk([{ dir: 'older', draft: '' }, { dir: 'newer' }]);
|
||||
expect(texts[1]).toBe('');
|
||||
});
|
||||
test('reset clears overlays and returns to the draft endpoint', () => {
|
||||
const state = createState();
|
||||
const recalled = older(state, draft('stashed draft'));
|
||||
const editedState = older(recalled.state, draft('edited newest')).state;
|
||||
|
||||
test('coming back when not browsing does nothing', () => {
|
||||
expect(walk([{ dir: 'newer' }]).texts).toEqual([null]);
|
||||
});
|
||||
const reset = resetHistoryState(editedState, HISTORY);
|
||||
expect(reset.cursor).toBe(HISTORY.length);
|
||||
|
||||
test('the draft is stashed on entry, not overwritten by recalled text', () => {
|
||||
// The second `older` passes recalled text as the current text; it must
|
||||
// not replace what the user actually typed.
|
||||
const { texts } = walk([
|
||||
{ dir: 'older', draft: 'original draft' },
|
||||
{ dir: 'older', draft: 'newest' },
|
||||
{ dir: 'newer' },
|
||||
{ dir: 'newer' },
|
||||
]);
|
||||
expect(texts[3]).toBe('original draft');
|
||||
});
|
||||
const backIntoHistory = older(reset, draft('fresh draft'));
|
||||
expect(backIntoHistory.value).toEqual(draft('newest'));
|
||||
|
||||
test('the stash is cleared once restored', () => {
|
||||
const { state } = walk([{ dir: 'older', draft: 'draft' }, { dir: 'newer' }]);
|
||||
expect(state.stashedDraft).toBe('');
|
||||
const backToDraft = newer(backIntoHistory.state, draft('newest'));
|
||||
expect(backToDraft.value).toEqual(draft('fresh draft'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('a shrinking history', () => {
|
||||
test('an index past the end of a shorter history cannot step further back', () => {
|
||||
const state: HistoryState = { index: 5, stashedDraft: 'draft' };
|
||||
expect(stepOlder(state, HISTORY, 'x').text).toBeNull();
|
||||
describe('message history synchronization', () => {
|
||||
test('resets when the active identity changes', () => {
|
||||
const state = createState();
|
||||
const recalled = older(state, draft('draft')).state;
|
||||
|
||||
const reset = syncHistoryState(recalled, HISTORY, 'session-b');
|
||||
expect(reset.cursor).toBe(HISTORY.length);
|
||||
|
||||
const backIntoHistory = older(reset, draft('new identity draft'));
|
||||
expect(backIntoHistory.value).toEqual(draft('newest'));
|
||||
|
||||
const restored = newer(backIntoHistory.state, draft('newest'));
|
||||
expect(restored.value).toEqual(draft('new identity draft'));
|
||||
});
|
||||
|
||||
test('reset after send drops stale overlays before the sent message is appended', () => {
|
||||
const state = createState();
|
||||
const recalled = older(state, draft('sent draft')).state;
|
||||
const reset = resetHistoryState(recalled, HISTORY);
|
||||
const appendedHistory = [...HISTORY, draft('sent draft')];
|
||||
const synced = syncHistoryState(reset, appendedHistory, 'session-a');
|
||||
|
||||
const recalledSent = older(synced, draft(''), appendedHistory);
|
||||
expect(recalledSent.value).toEqual(draft('sent draft'));
|
||||
|
||||
const restored = newer(recalledSent.state, draft('sent draft'), appendedHistory);
|
||||
expect(restored.value).toEqual(draft(''));
|
||||
});
|
||||
|
||||
test('tracks an external append while the user stays at the endpoint', () => {
|
||||
const state = createState();
|
||||
const appendedHistory = [...HISTORY, draft('latest from elsewhere')];
|
||||
const synced = syncHistoryState(state, appendedHistory, 'session-a');
|
||||
|
||||
const recalled = older(synced, draft('draft at endpoint'), appendedHistory);
|
||||
expect(recalled.value).toEqual(draft('latest from elsewhere'));
|
||||
});
|
||||
|
||||
test('keeps the same logical entry selected while browsing during an external append', () => {
|
||||
const state = createState();
|
||||
const browsingNewest = older(state, draft('draft')).state;
|
||||
const appendedHistory = [...HISTORY, draft('newest appended')];
|
||||
const synced = syncHistoryState(browsingNewest, appendedHistory, 'session-a');
|
||||
|
||||
const newerEntry = newer(synced, draft('newest'), appendedHistory);
|
||||
expect(newerEntry.value).toEqual(draft('newest appended'));
|
||||
|
||||
const restored = newer(newerEntry.state, draft('newest appended'), appendedHistory);
|
||||
expect(restored.value).toEqual(draft('draft'));
|
||||
});
|
||||
|
||||
test('preserves the current logical entry when a 40-entry bucket trims its oldest value', () => {
|
||||
const history = Array.from({ length: 40 }, (_, index) => draft(`message-${index}`));
|
||||
const state = createState(history);
|
||||
const browsingMessage39 = older(state, draft('draft'), history).state;
|
||||
const browsingMessage38 = older(browsingMessage39, draft('message-39'), history).state;
|
||||
const trimmedAndAppended = [...history.slice(1), draft('message-40')];
|
||||
const synced = syncHistoryState(browsingMessage38, trimmedAndAppended, 'session-a');
|
||||
|
||||
expect(synced.cursor).toBe(37);
|
||||
|
||||
const newerEntry = newer(synced, draft('message-38'), trimmedAndAppended);
|
||||
expect(newerEntry.value).toEqual(draft('message-39'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useMessageHistory', () => {
|
||||
test('round-trips edited recalled entries and the live draft through the generic API', () => {
|
||||
const rendered = renderMessageHistory(HISTORY, 'session-a');
|
||||
|
||||
try {
|
||||
const draftAttachment = { id: 'draft-file' };
|
||||
const recalledAttachment = { id: 'edited-file' };
|
||||
|
||||
let recalledNewest: MessageHistoryValue<Attachment> | null = null;
|
||||
let recalledMiddle: MessageHistoryValue<Attachment> | null = null;
|
||||
let restoredNewest: MessageHistoryValue<Attachment> | null = null;
|
||||
let restoredDraft: MessageHistoryValue<Attachment> | null = null;
|
||||
|
||||
act(() => {
|
||||
recalledNewest = rendered.current().older(draft('draft text', [draftAttachment]));
|
||||
});
|
||||
act(() => {
|
||||
recalledMiddle = rendered.current().older(draft('newest', [recalledAttachment]));
|
||||
});
|
||||
act(() => {
|
||||
restoredNewest = rendered.current().newer(draft('middle'));
|
||||
});
|
||||
act(() => {
|
||||
restoredDraft = rendered.current().newer(draft('newest', [recalledAttachment]));
|
||||
});
|
||||
|
||||
expect(recalledNewest).toEqual(draft('newest'));
|
||||
expect(recalledMiddle).toEqual(draft('middle'));
|
||||
expect(restoredNewest).toEqual(draft('newest', [recalledAttachment]));
|
||||
expect(restoredDraft).toEqual(draft('draft text', [draftAttachment]));
|
||||
expect(rendered.current().isBrowsing).toBe(false);
|
||||
} finally {
|
||||
rendered.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test('reset exits browsing and drops stale overlays', () => {
|
||||
const rendered = renderMessageHistory(HISTORY, 'session-a');
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
rendered.current().older(draft('fresh draft'));
|
||||
});
|
||||
act(() => {
|
||||
rendered.current().older(draft('edited newest'));
|
||||
});
|
||||
|
||||
expect(rendered.current().isBrowsing).toBe(true);
|
||||
|
||||
act(() => {
|
||||
rendered.current().reset();
|
||||
});
|
||||
|
||||
expect(rendered.current().isBrowsing).toBe(false);
|
||||
|
||||
let recalled: MessageHistoryValue<Attachment> | null = null;
|
||||
let restoredDraft: MessageHistoryValue<Attachment> | null = null;
|
||||
|
||||
act(() => {
|
||||
recalled = rendered.current().older(draft('fresh draft'));
|
||||
});
|
||||
act(() => {
|
||||
restoredDraft = rendered.current().newer(draft('newest'));
|
||||
});
|
||||
|
||||
expect(recalled).toEqual(draft('newest'));
|
||||
expect(restoredDraft).toEqual(draft('fresh draft'));
|
||||
} finally {
|
||||
rendered.teardown();
|
||||
}
|
||||
});
|
||||
|
||||
test('resets browsing when the identity changes', () => {
|
||||
const rendered = renderMessageHistory(HISTORY, 'session-a');
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
rendered.current().older(draft('new identity draft'));
|
||||
});
|
||||
|
||||
expect(rendered.current().isBrowsing).toBe(true);
|
||||
|
||||
rendered.rerender(HISTORY, 'session-b');
|
||||
|
||||
expect(rendered.current().isBrowsing).toBe(false);
|
||||
|
||||
let recalled: MessageHistoryValue<Attachment> | null = null;
|
||||
let restoredDraft: MessageHistoryValue<Attachment> | null = null;
|
||||
|
||||
act(() => {
|
||||
recalled = rendered.current().older(draft('new identity draft'));
|
||||
});
|
||||
act(() => {
|
||||
restoredDraft = rendered.current().newer(draft('newest'));
|
||||
});
|
||||
|
||||
expect(recalled).toEqual(draft('newest'));
|
||||
expect(restoredDraft).toEqual(draft('new identity draft'));
|
||||
} finally {
|
||||
rendered.teardown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,99 +1,234 @@
|
||||
/**
|
||||
* Walking back through previously sent messages with the arrow keys.
|
||||
*
|
||||
* Entering history stashes whatever was typed so leaving it returns the user's
|
||||
* own text rather than the last recalled message — the composer is not a
|
||||
* terminal, and losing a half-written prompt to an arrow key is worse than not
|
||||
* having history at all.
|
||||
*
|
||||
* Index 0 is the most recent message and higher indices are older, matching
|
||||
* how the keys read: up goes further back.
|
||||
* History arrives oldest to newest. The cursor spans `0..history.length`, with
|
||||
* `history.length` reserved for the live draft endpoint. Moving away from a
|
||||
* cursor stores the current value as an overlay for that cursor so edits survive
|
||||
* round-trips through history.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/** Not browsing history. */
|
||||
export const HISTORY_IDLE = -1;
|
||||
export type MessageHistoryValue<TAttachment> = {
|
||||
text: string;
|
||||
attachments: readonly TAttachment[];
|
||||
};
|
||||
|
||||
export interface HistoryState {
|
||||
/** Index into the history, or HISTORY_IDLE when showing the user's draft. */
|
||||
index: number;
|
||||
/** The draft stashed on entry, restored on the way back out. */
|
||||
stashedDraft: string;
|
||||
export interface HistoryState<TAttachment> {
|
||||
cursor: number;
|
||||
identity: string;
|
||||
history: readonly MessageHistoryValue<TAttachment>[];
|
||||
overlays: ReadonlyMap<number, MessageHistoryValue<TAttachment>>;
|
||||
}
|
||||
|
||||
export const INITIAL_HISTORY_STATE: HistoryState = { index: HISTORY_IDLE, stashedDraft: '' };
|
||||
|
||||
/**
|
||||
* The outcome of an arrow key: the next state, and the text the composer
|
||||
* should show. A null text means the key does nothing and the composer keeps
|
||||
* what it has.
|
||||
*/
|
||||
export interface HistoryStep {
|
||||
state: HistoryState;
|
||||
text: string | null;
|
||||
export interface HistoryStep<TAttachment> {
|
||||
state: HistoryState<TAttachment>;
|
||||
value: MessageHistoryValue<TAttachment> | null;
|
||||
}
|
||||
|
||||
const unchanged = (state: HistoryState): HistoryStep => ({ state, text: null });
|
||||
|
||||
/** Step further back in history. `currentText` is stashed on entry. */
|
||||
export function stepOlder(
|
||||
state: HistoryState,
|
||||
history: readonly string[],
|
||||
currentText: string,
|
||||
): HistoryStep {
|
||||
if (history.length === 0) return unchanged(state);
|
||||
|
||||
if (state.index === HISTORY_IDLE) {
|
||||
return { state: { index: 0, stashedDraft: currentText }, text: history[0] };
|
||||
}
|
||||
if (state.index >= history.length - 1) return unchanged(state);
|
||||
|
||||
const index = state.index + 1;
|
||||
return { state: { ...state, index }, text: history[index] };
|
||||
}
|
||||
|
||||
/** Step back toward the draft, restoring it once past the newest message. */
|
||||
export function stepNewer(state: HistoryState, history: readonly string[]): HistoryStep {
|
||||
if (state.index === HISTORY_IDLE) return unchanged(state);
|
||||
|
||||
if (state.index === 0) {
|
||||
return { state: INITIAL_HISTORY_STATE, text: state.stashedDraft };
|
||||
}
|
||||
|
||||
const index = state.index - 1;
|
||||
return { state: { ...state, index }, text: history[index] };
|
||||
}
|
||||
|
||||
export interface MessageHistory {
|
||||
/** True while showing a recalled message rather than the user's draft. */
|
||||
export interface MessageHistory<TAttachment> {
|
||||
isBrowsing: boolean;
|
||||
/** Recall an older message; returns null when already at the oldest. */
|
||||
older: (currentText: string) => string | null;
|
||||
/** Return toward the draft; returns null when not browsing. */
|
||||
newer: () => string | null;
|
||||
/** Leave history, discarding the stashed draft. Called after a send. */
|
||||
older: (currentValue: MessageHistoryValue<TAttachment>) => MessageHistoryValue<TAttachment> | null;
|
||||
newer: (currentValue: MessageHistoryValue<TAttachment>) => MessageHistoryValue<TAttachment> | null;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useMessageHistory(history: readonly string[]): MessageHistory {
|
||||
const [state, setState] = React.useState<HistoryState>(INITIAL_HISTORY_STATE);
|
||||
|
||||
const older = React.useCallback((currentText: string) => {
|
||||
const step = stepOlder(state, history, currentText);
|
||||
if (step.text === null) return null;
|
||||
setState(step.state);
|
||||
return step.text;
|
||||
}, [history, state]);
|
||||
|
||||
const newer = React.useCallback(() => {
|
||||
const step = stepNewer(state, history);
|
||||
if (step.text === null) return null;
|
||||
setState(step.state);
|
||||
return step.text;
|
||||
}, [history, state]);
|
||||
|
||||
const reset = React.useCallback(() => setState(INITIAL_HISTORY_STATE), []);
|
||||
|
||||
return { isBrowsing: state.index !== HISTORY_IDLE, older, newer, reset };
|
||||
function createEmptyValue<TAttachment>(): MessageHistoryValue<TAttachment> {
|
||||
return { text: '', attachments: [] };
|
||||
}
|
||||
|
||||
function valuesEqual<TAttachment>(a: MessageHistoryValue<TAttachment>, b: MessageHistoryValue<TAttachment>): boolean {
|
||||
if (a.text !== b.text) return false;
|
||||
if (a.attachments.length !== b.attachments.length) return false;
|
||||
for (let index = 0; index < a.attachments.length; index += 1) {
|
||||
if (!Object.is(a.attachments[index], b.attachments[index])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sliceEqual<TAttachment>(
|
||||
left: readonly MessageHistoryValue<TAttachment>[],
|
||||
leftStart: number,
|
||||
right: readonly MessageHistoryValue<TAttachment>[],
|
||||
rightStart: number,
|
||||
length: number,
|
||||
): boolean {
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!valuesEqual(left[leftStart + index]!, right[rightStart + index]!)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function countTrimmedEntries<TAttachment>(
|
||||
previousHistory: readonly MessageHistoryValue<TAttachment>[],
|
||||
nextHistory: readonly MessageHistoryValue<TAttachment>[],
|
||||
): number {
|
||||
const maxOverlap = Math.min(previousHistory.length, nextHistory.length);
|
||||
for (let overlap = maxOverlap; overlap >= 0; overlap -= 1) {
|
||||
if (sliceEqual(previousHistory, previousHistory.length - overlap, nextHistory, 0, overlap)) {
|
||||
return previousHistory.length - overlap;
|
||||
}
|
||||
}
|
||||
return previousHistory.length;
|
||||
}
|
||||
|
||||
function readCursorValue<TAttachment>(
|
||||
cursor: number,
|
||||
history: readonly MessageHistoryValue<TAttachment>[],
|
||||
overlays: ReadonlyMap<number, MessageHistoryValue<TAttachment>>,
|
||||
): MessageHistoryValue<TAttachment> {
|
||||
const overlay = overlays.get(cursor);
|
||||
if (overlay) return overlay;
|
||||
if (cursor === history.length) return createEmptyValue<TAttachment>();
|
||||
return history[cursor] ?? createEmptyValue<TAttachment>();
|
||||
}
|
||||
|
||||
function withOverlay<TAttachment>(
|
||||
overlays: ReadonlyMap<number, MessageHistoryValue<TAttachment>>,
|
||||
cursor: number,
|
||||
value: MessageHistoryValue<TAttachment>,
|
||||
): ReadonlyMap<number, MessageHistoryValue<TAttachment>> {
|
||||
const nextOverlays = new Map(overlays);
|
||||
nextOverlays.set(cursor, value);
|
||||
return nextOverlays;
|
||||
}
|
||||
|
||||
function remapCursor(oldCursor: number, oldLength: number, newLength: number, trimmed: number): number {
|
||||
if (oldCursor === oldLength) return newLength;
|
||||
if (oldCursor < trimmed) return Math.min(newLength, 0);
|
||||
return Math.min(newLength, oldCursor - trimmed);
|
||||
}
|
||||
|
||||
function remapOverlays<TAttachment>(
|
||||
overlays: ReadonlyMap<number, MessageHistoryValue<TAttachment>>,
|
||||
oldLength: number,
|
||||
newLength: number,
|
||||
trimmed: number,
|
||||
): ReadonlyMap<number, MessageHistoryValue<TAttachment>> {
|
||||
const nextOverlays = new Map<number, MessageHistoryValue<TAttachment>>();
|
||||
for (const [cursor, value] of overlays) {
|
||||
if (cursor === oldLength) {
|
||||
nextOverlays.set(newLength, value);
|
||||
continue;
|
||||
}
|
||||
if (cursor < trimmed) continue;
|
||||
nextOverlays.set(Math.min(newLength, cursor - trimmed), value);
|
||||
}
|
||||
return nextOverlays;
|
||||
}
|
||||
|
||||
export function createHistoryState<TAttachment>(
|
||||
history: readonly MessageHistoryValue<TAttachment>[],
|
||||
identity: string,
|
||||
): HistoryState<TAttachment> {
|
||||
return {
|
||||
cursor: history.length,
|
||||
identity,
|
||||
history,
|
||||
overlays: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetHistoryState<TAttachment>(
|
||||
state: HistoryState<TAttachment>,
|
||||
history: readonly MessageHistoryValue<TAttachment>[] = state.history,
|
||||
): HistoryState<TAttachment> {
|
||||
return {
|
||||
cursor: history.length,
|
||||
identity: state.identity,
|
||||
history,
|
||||
overlays: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function stepOlder<TAttachment>(
|
||||
state: HistoryState<TAttachment>,
|
||||
history: readonly MessageHistoryValue<TAttachment>[],
|
||||
currentValue: MessageHistoryValue<TAttachment>,
|
||||
): HistoryStep<TAttachment> {
|
||||
if (history.length === 0 || state.cursor === 0) {
|
||||
return { state: { ...state, history }, value: null };
|
||||
}
|
||||
|
||||
const overlays = withOverlay(state.overlays, state.cursor, currentValue);
|
||||
const cursor = state.cursor - 1;
|
||||
return {
|
||||
state: { ...state, cursor, history, overlays },
|
||||
value: readCursorValue(cursor, history, overlays),
|
||||
};
|
||||
}
|
||||
|
||||
export function stepNewer<TAttachment>(
|
||||
state: HistoryState<TAttachment>,
|
||||
history: readonly MessageHistoryValue<TAttachment>[],
|
||||
currentValue: MessageHistoryValue<TAttachment>,
|
||||
): HistoryStep<TAttachment> {
|
||||
if (state.cursor === history.length) {
|
||||
return { state: { ...state, history }, value: null };
|
||||
}
|
||||
|
||||
const overlays = withOverlay(state.overlays, state.cursor, currentValue);
|
||||
const cursor = state.cursor + 1;
|
||||
return {
|
||||
state: { ...state, cursor, history, overlays },
|
||||
value: readCursorValue(cursor, history, overlays),
|
||||
};
|
||||
}
|
||||
|
||||
export function syncHistoryState<TAttachment>(
|
||||
state: HistoryState<TAttachment>,
|
||||
history: readonly MessageHistoryValue<TAttachment>[],
|
||||
identity: string,
|
||||
): HistoryState<TAttachment> {
|
||||
if (state.identity !== identity) {
|
||||
return createHistoryState(history, identity);
|
||||
}
|
||||
|
||||
if (state.history === history) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const trimmed = countTrimmedEntries(state.history, history);
|
||||
return {
|
||||
cursor: remapCursor(state.cursor, state.history.length, history.length, trimmed),
|
||||
identity,
|
||||
history,
|
||||
overlays: remapOverlays(state.overlays, state.history.length, history.length, trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
export function useMessageHistory<TAttachment>(
|
||||
history: readonly MessageHistoryValue<TAttachment>[],
|
||||
identity: string,
|
||||
): MessageHistory<TAttachment> {
|
||||
const [state, setState] = React.useState(() => createHistoryState(history, identity));
|
||||
|
||||
React.useEffect(() => {
|
||||
setState((currentState) => syncHistoryState(currentState, history, identity));
|
||||
}, [history, identity]);
|
||||
|
||||
const older = React.useCallback((currentValue: MessageHistoryValue<TAttachment>) => {
|
||||
const step = stepOlder(state, history, currentValue);
|
||||
if (step.value === null) return null;
|
||||
setState(step.state);
|
||||
return step.value;
|
||||
}, [history, state]);
|
||||
|
||||
const newer = React.useCallback((currentValue: MessageHistoryValue<TAttachment>) => {
|
||||
const step = stepNewer(state, history, currentValue);
|
||||
if (step.value === null) return null;
|
||||
setState(step.state);
|
||||
return step.value;
|
||||
}, [history, state]);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setState((currentState) => resetHistoryState(currentState, history));
|
||||
}, [history]);
|
||||
|
||||
return {
|
||||
isBrowsing: state.cursor !== history.length,
|
||||
older,
|
||||
newer,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import { createInputHistorySubmission, type InputHistoryAttachment, type InputHistoryEntry } from '@/stores/useInputHistoryStore';
|
||||
|
||||
import {
|
||||
buildChatInputHistorySubmissions,
|
||||
buildInputHistoryNavigatorIdentity,
|
||||
mapInputHistoryEntriesToValues,
|
||||
} from './inputHistory';
|
||||
|
||||
const ATTACHMENT: AttachedFile = {
|
||||
id: 'file-1',
|
||||
file: new File(['hello'], 'notes.txt', { type: 'text/plain' }),
|
||||
dataUrl: 'file:///repo/notes.txt',
|
||||
mimeType: 'text/plain',
|
||||
filename: 'notes.txt',
|
||||
size: 5,
|
||||
source: 'local',
|
||||
serverPath: '/repo/notes.txt',
|
||||
};
|
||||
|
||||
describe('buildChatInputHistorySubmissions', () => {
|
||||
test('keeps raw queued submissions first and raw composer submission last', () => {
|
||||
const submissions = buildChatInputHistorySubmissions({
|
||||
inputMode: 'normal',
|
||||
queuedMessages: [
|
||||
{ content: '/queued one', attachments: [ATTACHMENT] },
|
||||
{ content: '/queued two', attachments: [] },
|
||||
],
|
||||
composerText: '/composer raw',
|
||||
composerAttachments: [ATTACHMENT],
|
||||
includeComposer: true,
|
||||
});
|
||||
|
||||
expect(submissions?.map((submission) => submission.text)).toEqual([
|
||||
'/queued one',
|
||||
'/queued two',
|
||||
'/composer raw',
|
||||
]);
|
||||
expect(submissions?.[0]).toEqual(createInputHistorySubmission('/queued one', [ATTACHMENT]));
|
||||
expect(submissions?.[2]).toEqual(createInputHistorySubmission('/composer raw', [ATTACHMENT]));
|
||||
});
|
||||
|
||||
test('omits history submissions for shell mode', () => {
|
||||
expect(buildChatInputHistorySubmissions({
|
||||
inputMode: 'shell',
|
||||
queuedMessages: [{ content: 'echo hello', attachments: [ATTACHMENT] }],
|
||||
composerText: 'pwd',
|
||||
composerAttachments: [ATTACHMENT],
|
||||
includeComposer: true,
|
||||
})).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapInputHistoryEntriesToValues', () => {
|
||||
test('keeps chronological order and materializes supported attachments', () => {
|
||||
const entries: InputHistoryEntry[] = [
|
||||
{
|
||||
text: 'oldest',
|
||||
attachmentKeys: ['a'],
|
||||
restorableAttachments: [{
|
||||
key: 'server-file',
|
||||
source: 'file-url',
|
||||
filename: 'server.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 11,
|
||||
reference: '/repo/server.txt',
|
||||
}],
|
||||
submittedAt: 1,
|
||||
},
|
||||
{
|
||||
text: 'newest',
|
||||
attachmentKeys: ['b'],
|
||||
restorableAttachments: [{
|
||||
key: 'vscode-file',
|
||||
source: 'vscode-file',
|
||||
filename: 'editor.ts',
|
||||
mimeType: 'text/plain',
|
||||
size: 22,
|
||||
reference: '/repo/editor.ts',
|
||||
}],
|
||||
submittedAt: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const values = mapInputHistoryEntriesToValues(entries);
|
||||
|
||||
expect(values.map((value) => value.text)).toEqual(['oldest', 'newest']);
|
||||
expect(values[0]?.attachments[0]?.filename).toBe('server.txt');
|
||||
expect(values[0]?.attachments[0]?.dataUrl).toBe('/repo/server.txt');
|
||||
expect(values[0]?.attachments[0]?.source).toBe('local');
|
||||
expect(values[1]?.attachments[0]?.filename).toBe('editor.ts');
|
||||
expect(values[1]?.attachments[0]?.vscodePath).toBe('/repo/editor.ts');
|
||||
expect(values[1]?.attachments[0]?.vscodeSource).toBe('file');
|
||||
expect(values[1]?.attachments[0]?.source).toBe('vscode');
|
||||
});
|
||||
|
||||
test('drops unsupported attachment descriptors', () => {
|
||||
const unsupported: InputHistoryAttachment = {
|
||||
key: 'bad',
|
||||
source: 'file-url',
|
||||
filename: 'bad.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 1,
|
||||
reference: 'data:text/plain;base64,Zm9v',
|
||||
};
|
||||
const entries: InputHistoryEntry[] = [{
|
||||
text: 'value',
|
||||
attachmentKeys: ['bad'],
|
||||
restorableAttachments: [unsupported],
|
||||
submittedAt: 1,
|
||||
}];
|
||||
|
||||
expect(mapInputHistoryEntriesToValues(entries)[0]?.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildInputHistoryNavigatorIdentity', () => {
|
||||
test('includes scope and full identity so bucket changes reset navigation', () => {
|
||||
expect(buildInputHistoryNavigatorIdentity('global', {
|
||||
runtimeKey: 'runtime-a',
|
||||
directory: '/repo',
|
||||
sessionId: 'session-1',
|
||||
})).toBe('global\nruntime-a\n/repo\nsession-1');
|
||||
|
||||
expect(buildInputHistoryNavigatorIdentity('session', {
|
||||
runtimeKey: 'runtime-a',
|
||||
directory: '/repo',
|
||||
sessionId: 'session-1',
|
||||
})).toBe('session\nruntime-a\n/repo\nsession-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { MessageHistoryValue } from './composer/state/useMessageHistory';
|
||||
import type { InputHistoryScope } from '@/lib/inputHistoryScope';
|
||||
import {
|
||||
createInputHistorySubmission,
|
||||
type InputHistoryAttachment,
|
||||
type InputHistoryEntry,
|
||||
type InputHistoryIdentity,
|
||||
type InputHistorySubmission,
|
||||
} from '@/stores/useInputHistoryStore';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
|
||||
type HistoryQueuedMessage = {
|
||||
content: string;
|
||||
attachments?: readonly AttachedFile[];
|
||||
};
|
||||
|
||||
type BuildHistorySubmissionsArgs = {
|
||||
inputMode: 'normal' | 'shell';
|
||||
queuedMessages: readonly HistoryQueuedMessage[];
|
||||
composerText: string;
|
||||
composerAttachments: readonly AttachedFile[];
|
||||
includeComposer: boolean;
|
||||
};
|
||||
|
||||
const FILE_URI_PREFIX = 'file://';
|
||||
|
||||
const encodeFilePath = (filepath: string): string => {
|
||||
let normalized = filepath.replace(/\\/g, '/');
|
||||
if (/^[A-Za-z]:/.test(normalized)) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
return normalized
|
||||
.split('/')
|
||||
.map((segment, index) => {
|
||||
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment;
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join('/');
|
||||
};
|
||||
|
||||
const toFileUrl = (filepath: string): string => {
|
||||
const normalized = filepath.replace(/\\/g, '/').trim();
|
||||
if (normalized.toLowerCase().startsWith(FILE_URI_PREFIX)) {
|
||||
return normalized;
|
||||
}
|
||||
return `${FILE_URI_PREFIX}${encodeFilePath(normalized)}`;
|
||||
};
|
||||
|
||||
export function buildChatInputHistorySubmissions({
|
||||
inputMode,
|
||||
queuedMessages,
|
||||
composerText,
|
||||
composerAttachments,
|
||||
includeComposer,
|
||||
}: BuildHistorySubmissionsArgs): InputHistorySubmission[] | undefined {
|
||||
if (inputMode === 'shell') return undefined;
|
||||
|
||||
const submissions = queuedMessages.map((queued) => (
|
||||
createInputHistorySubmission(queued.content, queued.attachments ?? [])
|
||||
));
|
||||
|
||||
if (includeComposer) {
|
||||
submissions.push(createInputHistorySubmission(composerText, composerAttachments));
|
||||
}
|
||||
|
||||
return submissions.length > 0 ? submissions : undefined;
|
||||
}
|
||||
|
||||
function materializeHistoryAttachment(attachment: InputHistoryAttachment): AttachedFile | null {
|
||||
if (attachment.source === 'file-url') {
|
||||
if (!attachment.reference || attachment.reference.startsWith('data:')) return null;
|
||||
return {
|
||||
id: `history-${attachment.key}`,
|
||||
file: new File([], attachment.filename, { type: attachment.mimeType }),
|
||||
dataUrl: attachment.reference,
|
||||
mimeType: attachment.mimeType,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
source: 'local',
|
||||
serverPath: attachment.reference,
|
||||
};
|
||||
}
|
||||
|
||||
if (attachment.source === 'vscode-file') {
|
||||
return {
|
||||
id: `history-${attachment.key}`,
|
||||
file: new File([], attachment.filename, { type: attachment.mimeType }),
|
||||
dataUrl: toFileUrl(attachment.reference),
|
||||
mimeType: attachment.mimeType,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
source: 'vscode',
|
||||
vscodePath: attachment.reference,
|
||||
vscodeSource: 'file',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mapInputHistoryEntriesToValues(
|
||||
entries: readonly InputHistoryEntry[],
|
||||
): Array<MessageHistoryValue<AttachedFile>> {
|
||||
return entries.map((entry) => ({
|
||||
text: entry.text,
|
||||
attachments: entry.restorableAttachments
|
||||
.map(materializeHistoryAttachment)
|
||||
.filter((attachment): attachment is AttachedFile => attachment !== null),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildInputHistoryNavigatorIdentity(
|
||||
scope: InputHistoryScope,
|
||||
identity: InputHistoryIdentity | null,
|
||||
): string {
|
||||
if (!identity) return `${scope}\nmissing`;
|
||||
return [scope, identity.runtimeKey, identity.directory, identity.sessionId].join('\n');
|
||||
}
|
||||
@@ -204,6 +204,8 @@ const ChatSectionContent: React.FC = () => {
|
||||
'splitAssistantMessageActions',
|
||||
'subagentReadOnlyBanner',
|
||||
'diffLayout',
|
||||
'inputHistoryScope',
|
||||
'inputHistoryLimit',
|
||||
'dotfiles',
|
||||
'fileViewerPreview',
|
||||
'followUpBehavior',
|
||||
|
||||
@@ -61,9 +61,18 @@ import {
|
||||
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { TerminalShellOption } from '@/lib/api/types';
|
||||
import {
|
||||
MAX_INPUT_HISTORY_LIMIT,
|
||||
MIN_INPUT_HISTORY_LIMIT,
|
||||
isInputHistoryLimit,
|
||||
type InputHistoryScope,
|
||||
} from '@/lib/inputHistoryScope';
|
||||
import { isTerminalShell } from '@/lib/terminalShell';
|
||||
import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
|
||||
import { formatShortcutForDisplay } from '@/lib/shortcuts';
|
||||
import {
|
||||
useInputHistoryStore,
|
||||
} from '@/stores/useInputHistoryStore';
|
||||
|
||||
interface Option<T extends string> {
|
||||
id: T;
|
||||
@@ -279,11 +288,22 @@ const LARGE_TEXT_PASTE_BEHAVIOR_OPTIONS: Option<LargeTextPasteBehavior>[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const INPUT_HISTORY_SCOPE_OPTIONS: Option<InputHistoryScope>[] = [
|
||||
{
|
||||
id: 'global',
|
||||
labelKey: 'settings.openchamber.visual.option.inputHistoryScope.global.label',
|
||||
},
|
||||
{
|
||||
id: 'session',
|
||||
labelKey: 'settings.openchamber.visual.option.inputHistoryScope.session.label',
|
||||
},
|
||||
];
|
||||
|
||||
const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' => {
|
||||
return mode === 'markdown' ? 'markdown' : 'plain';
|
||||
};
|
||||
|
||||
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
|
||||
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'inputHistoryScope' | 'inputHistoryLimit' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
|
||||
|
||||
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
|
||||
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
|
||||
@@ -374,6 +394,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
|
||||
const followUpBehavior = useMessageQueueStore(state => state.followUpBehavior);
|
||||
const setFollowUpBehavior = useMessageQueueStore(state => state.setFollowUpBehavior);
|
||||
const inputHistoryScope = useInputHistoryStore(state => state.scope);
|
||||
const inputHistoryLimit = useInputHistoryStore(state => state.entryLimit);
|
||||
const applyInputHistoryScope = useInputHistoryStore(state => state.applyScope);
|
||||
const applyInputHistoryLimit = useInputHistoryStore(state => state.applyEntryLimit);
|
||||
const persistChatDraft = useUIStore(state => state.persistChatDraft);
|
||||
const setPersistChatDraft = useUIStore(state => state.setPersistChatDraft);
|
||||
const inputSpellcheckEnabled = useUIStore(state => state.inputSpellcheckEnabled);
|
||||
@@ -560,6 +584,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
void updateDesktopSettings({ messageStreamTransport: mode });
|
||||
}, [setMessageStreamTransport]);
|
||||
|
||||
const handleInputHistoryScopeChange = React.useCallback((scope: InputHistoryScope) => {
|
||||
applyInputHistoryScope(scope);
|
||||
void updateDesktopSettings({ inputHistoryScope: scope });
|
||||
}, [applyInputHistoryScope]);
|
||||
|
||||
const handleInputHistoryLimitChange = React.useCallback((value: number) => {
|
||||
const nextLimit = Math.round(value);
|
||||
if (!isInputHistoryLimit(nextLimit)) {
|
||||
return;
|
||||
}
|
||||
applyInputHistoryLimit(nextLimit);
|
||||
void updateDesktopSettings({ inputHistoryLimit: nextLimit });
|
||||
}, [applyInputHistoryLimit]);
|
||||
|
||||
const handleActivityRenderModeChange = React.useCallback((mode: 'collapsed' | 'summary') => {
|
||||
setActivityRenderMode(mode);
|
||||
void updateDesktopSettings({ activityRenderMode: mode });
|
||||
@@ -667,6 +705,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
|| shouldShow('fileViewerPreview')
|
||||
|| shouldShow('reasoning')
|
||||
|| shouldShow('followUpBehavior')
|
||||
|| shouldShow('inputHistoryScope')
|
||||
|| shouldShow('inputHistoryLimit')
|
||||
|| shouldShow('persistDraft')
|
||||
|| shouldShow('largeTextPaste')
|
||||
|| shouldShow('showToolFileIcons')
|
||||
@@ -679,7 +719,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
const showBehaviorMessageOptions = shouldShow('userMessageRendering')
|
||||
|| shouldShow('mermaidRendering')
|
||||
|| (shouldShow('diffLayout') && !isVSCode)
|
||||
|| shouldShow('followUpBehavior');
|
||||
|| shouldShow('followUpBehavior')
|
||||
|| shouldShow('inputHistoryScope')
|
||||
|| shouldShow('inputHistoryLimit');
|
||||
const showBehaviorFeatureCheckboxes = shouldShow('sessionAssist')
|
||||
|| (shouldShow('sessionGoal') && !isVSCode)
|
||||
|| shouldShow('subagentReadOnlyBanner')
|
||||
@@ -1755,6 +1797,51 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
|
||||
</SettingsRadioGroup>
|
||||
</SettingsControlGroup>
|
||||
)}
|
||||
|
||||
{shouldShow('inputHistoryScope') && (
|
||||
<SettingsControlGroup
|
||||
title={t('settings.openchamber.visual.field.inputHistoryScope')}
|
||||
info={t('settings.openchamber.visual.field.inputHistoryScopeDescription')}
|
||||
settingsItem="chat.input-history-scope"
|
||||
>
|
||||
<SettingsRadioGroup aria-label={t('settings.openchamber.visual.section.inputHistoryScopeAria')}>
|
||||
{INPUT_HISTORY_SCOPE_OPTIONS.map((option) => (
|
||||
<SettingsRadioOption
|
||||
key={option.id}
|
||||
selected={inputHistoryScope === option.id}
|
||||
onSelect={() => handleInputHistoryScopeChange(option.id)}
|
||||
label={tUnsafe(option.labelKey)}
|
||||
ariaLabel={tUnsafe(option.labelKey)}
|
||||
/>
|
||||
))}
|
||||
</SettingsRadioGroup>
|
||||
</SettingsControlGroup>
|
||||
)}
|
||||
|
||||
{shouldShow('inputHistoryLimit') && (
|
||||
<SettingsControlGroup
|
||||
title={t('settings.openchamber.visual.field.inputHistoryLimit')}
|
||||
description={t('settings.openchamber.visual.field.inputHistoryLimitDescription')}
|
||||
contentClassName={SETTINGS_CONTROL_CLUSTER_CLASS}
|
||||
settingsItem="chat.input-history-limit"
|
||||
>
|
||||
<div className={SETTINGS_NUMBER_STEPPER_ROW_CLASS}>
|
||||
<NumberInput
|
||||
value={inputHistoryLimit}
|
||||
onValueChange={handleInputHistoryLimitChange}
|
||||
min={MIN_INPUT_HISTORY_LIMIT}
|
||||
max={MAX_INPUT_HISTORY_LIMIT}
|
||||
step={1}
|
||||
className={SETTINGS_NUMBER_INPUT_CLASS}
|
||||
deferExternalValueWhileFocused
|
||||
aria-label={t('settings.openchamber.visual.field.inputHistoryLimitAria')}
|
||||
/>
|
||||
<span className={SETTINGS_NUMBER_UNIT_CLASS}>
|
||||
{t('settings.openchamber.visual.field.inputHistoryLimitUnit')}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsControlGroup>
|
||||
)}
|
||||
</SettingsTwoColumn>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
@@ -249,6 +249,7 @@ interface ControlledProps {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
deferExternalValueWhileFocused?: boolean;
|
||||
}
|
||||
|
||||
interface ControlledHandle {
|
||||
@@ -258,6 +259,9 @@ interface ControlledHandle {
|
||||
rerenderWith(value: number): void;
|
||||
getButton(label: string): FakeNode | null;
|
||||
getButtonDisabled(label: string): boolean;
|
||||
getInputValue(): string;
|
||||
focusInput(): void;
|
||||
pressEnter(): void;
|
||||
typeInput(value: string): void;
|
||||
blurInput(): void;
|
||||
unmount(): void;
|
||||
@@ -285,6 +289,7 @@ function mountControlled(props: ControlledProps): ControlledHandle {
|
||||
min: props.min,
|
||||
max: props.max,
|
||||
step: props.step,
|
||||
deferExternalValueWhileFocused: props.deferExternalValueWhileFocused,
|
||||
onValueChange: (v: number) => recorded.push(v),
|
||||
}),
|
||||
);
|
||||
@@ -339,15 +344,21 @@ function mountControlled(props: ControlledProps): ControlledHandle {
|
||||
}
|
||||
|
||||
function readInputProps(): {
|
||||
onChange: (e: unknown) => void;
|
||||
onBlur: (e: unknown) => void;
|
||||
value: string;
|
||||
onChange: (event: { target: { value: string } }) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur: () => void;
|
||||
onKeyDown: (event: { key: string; defaultPrevented: boolean }) => void;
|
||||
} {
|
||||
const input = findInputNode();
|
||||
const propsKey = Object.keys(input).find((k) => k.startsWith("__reactProps"));
|
||||
if (!propsKey) throw new Error("Input has no __reactProps");
|
||||
return (input as unknown as Record<string, {
|
||||
onChange: (e: unknown) => void;
|
||||
onBlur: (e: unknown) => void;
|
||||
value: string;
|
||||
onChange: (event: { target: { value: string } }) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur: () => void;
|
||||
onKeyDown: (event: { key: string; defaultPrevented: boolean }) => void;
|
||||
}>)[propsKey];
|
||||
}
|
||||
|
||||
@@ -372,6 +383,23 @@ function mountControlled(props: ControlledProps): ControlledHandle {
|
||||
const props = (btn as unknown as Record<string, { disabled?: boolean }>)[propsKey];
|
||||
return Boolean(props.disabled);
|
||||
},
|
||||
getInputValue() {
|
||||
return readInputProps().value;
|
||||
},
|
||||
focusInput() {
|
||||
const input = findInputNode();
|
||||
const props = readInputProps();
|
||||
doc.activeElement = input;
|
||||
act(() => {
|
||||
props.onFocus?.();
|
||||
});
|
||||
},
|
||||
pressEnter() {
|
||||
const props = readInputProps();
|
||||
act(() => {
|
||||
props.onKeyDown({ key: "Enter", defaultPrevented: false });
|
||||
});
|
||||
},
|
||||
typeInput(value: string) {
|
||||
// Look the input up fresh each time so we always invoke the handler
|
||||
// currently bound by the most recent render.
|
||||
@@ -385,7 +413,7 @@ function mountControlled(props: ControlledProps): ControlledHandle {
|
||||
// the props object, and we want the handler bound to the latest draft.
|
||||
const props = readInputProps();
|
||||
act(() => {
|
||||
props.onBlur({});
|
||||
props.onBlur();
|
||||
});
|
||||
},
|
||||
unmount() {
|
||||
@@ -535,6 +563,52 @@ describe("NumberInput rapid-click stepper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("focused draft survives a parent rerender with the previous value until blur", () => {
|
||||
withHandle(
|
||||
{
|
||||
initialValue: 40,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
deferExternalValueWhileFocused: true,
|
||||
},
|
||||
(handle) => {
|
||||
handle.focusInput();
|
||||
handle.typeInput("100");
|
||||
|
||||
expect(handle.recorded).toEqual([]);
|
||||
|
||||
handle.rerenderWith(40);
|
||||
|
||||
expect(handle.getInputValue()).toBe("100");
|
||||
|
||||
handle.blurInput();
|
||||
|
||||
expect(handle.recorded).toEqual([100]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("focused draft commits once when Enter settles it", () => {
|
||||
withHandle(
|
||||
{
|
||||
initialValue: 40,
|
||||
min: 1,
|
||||
max: 100,
|
||||
step: 1,
|
||||
deferExternalValueWhileFocused: true,
|
||||
},
|
||||
(handle) => {
|
||||
handle.focusInput();
|
||||
handle.typeInput("100");
|
||||
|
||||
handle.pressEnter();
|
||||
|
||||
expect(handle.recorded).toEqual([100]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("typed value below min is clamped on blur and the stepper respects the clamped base", () => {
|
||||
withHandle({ initialValue: 100, min: 50, max: 200, step: 5 }, (handle) => {
|
||||
// 1) User types "20" (below min). handleChange commits 20, which
|
||||
|
||||
@@ -16,6 +16,7 @@ interface NumberInputProps
|
||||
fallbackValue?: number
|
||||
onClear?: () => void
|
||||
emptyLabel?: string
|
||||
deferExternalValueWhileFocused?: boolean
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
@@ -50,10 +51,13 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
className,
|
||||
containerClassName,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
fallbackValue,
|
||||
onClear,
|
||||
emptyLabel = '—',
|
||||
deferExternalValueWhileFocused = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
@@ -61,6 +65,7 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
const { t } = useI18n()
|
||||
const [draft, setDraft] = React.useState(() => (value == null ? '' : String(value)))
|
||||
const { isMobile } = useDeviceInfo()
|
||||
const isFocusedRef = React.useRef(false)
|
||||
const ignoreNextClickRef = React.useRef(false)
|
||||
const swallowNextClickCleanupRef = React.useRef<(() => void) | null>(null)
|
||||
|
||||
@@ -100,8 +105,11 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (deferExternalValueWhileFocused && isFocusedRef.current) {
|
||||
return
|
||||
}
|
||||
setDraft(value == null ? '' : String(value))
|
||||
}, [value])
|
||||
}, [deferExternalValueWhileFocused, value])
|
||||
|
||||
const baseValue = React.useMemo(() => {
|
||||
if (value !== undefined) return value
|
||||
@@ -134,6 +142,28 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
[max, min, onValueChange, step]
|
||||
)
|
||||
|
||||
const settleDraft = React.useCallback(() => {
|
||||
if (draft.trim() === '') {
|
||||
if (!onClear) {
|
||||
setDraft(value == null ? '' : String(value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = Number(draft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setDraft(value == null ? '' : String(value))
|
||||
return
|
||||
}
|
||||
|
||||
const clamped = clamp(parsed, min, max)
|
||||
const normalized = normalizeToStep(clamped, step)
|
||||
if (normalized !== value) {
|
||||
commitValue(parsed)
|
||||
}
|
||||
setDraft(String(normalized))
|
||||
}, [commitValue, draft, max, min, onClear, step, value])
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const nextDraft = event.target.value
|
||||
@@ -149,43 +179,44 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
return
|
||||
}
|
||||
|
||||
if (deferExternalValueWhileFocused) {
|
||||
return
|
||||
}
|
||||
|
||||
commitValue(parsed)
|
||||
},
|
||||
[commitValue, onClear]
|
||||
[commitValue, deferExternalValueWhileFocused, onClear]
|
||||
)
|
||||
|
||||
const handleFocus = React.useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
isFocusedRef.current = true
|
||||
onFocus?.(event)
|
||||
},
|
||||
[onFocus]
|
||||
)
|
||||
|
||||
const handleBlur = React.useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (draft.trim() === '') {
|
||||
if (!onClear) {
|
||||
setDraft(value == null ? '' : String(value))
|
||||
}
|
||||
onBlur?.(event)
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = Number(draft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setDraft(value == null ? '' : String(value))
|
||||
} else {
|
||||
const clamped = clamp(parsed, min, max)
|
||||
const normalized = normalizeToStep(clamped, step)
|
||||
if (normalized !== value) {
|
||||
// Route through commitValue so committedValueRef stays in sync with
|
||||
// the typed value. Without this, a typed-then-stepper sequence
|
||||
// would read a stale ref and drift. See number-input.test.tsx.
|
||||
commitValue(parsed)
|
||||
} else {
|
||||
// No effective change, but keep the ref aligned with the prop in
|
||||
// case it diverged via the baseValue useEffect.
|
||||
committedValueRef.current = normalized
|
||||
}
|
||||
setDraft(String(normalized))
|
||||
}
|
||||
isFocusedRef.current = false
|
||||
settleDraft()
|
||||
|
||||
onBlur?.(event)
|
||||
},
|
||||
[commitValue, draft, max, min, onBlur, onClear, step, value]
|
||||
[onBlur, settleDraft]
|
||||
)
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
if (deferExternalValueWhileFocused && event.key === 'Enter') {
|
||||
settleDraft()
|
||||
}
|
||||
},
|
||||
[deferExternalValueWhileFocused, onKeyDown, settleDraft]
|
||||
)
|
||||
|
||||
const incrementDisabled = Boolean(disabled || baseValue >= max)
|
||||
@@ -310,7 +341,9 @@ const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(
|
||||
inputMode={props.inputMode ?? 'numeric'}
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
|
||||
Reference in New Issue
Block a user