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:
Matt Visnovsky
2026-09-05 19:19:03 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 29480383cc
commit 1d6b15bc04
49 changed files with 3353 additions and 458 deletions
+58 -9
View File
@@ -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.
@@ -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
+62 -29
View File
@@ -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"