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