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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user