fix(chat): recall the current session's prompts by default; tidy the six merged PRs
Input history (#3035) shipped with "All projects" as the default scope and only recorded prompts sent after the upgrade, so ArrowUp showed other sessions' prompts and, once switched to "Current session", nothing at all. Default to the current session and merge the visible transcript's prompts with the persisted bucket. Existing sessions recall as they did before #3035, while new prompts keep their attachments and stay recallable after a revert hides them from the transcript. Cleanup across #1855, #2297, #3072, #3178, #3035 and #3135: drop the duplicate poll guards in the file content poller, the zod schema the VS Code package cannot depend on, a copied file-URL helper and stray whitespace; move the Enter-to-send strings into the settings namespace; document OPENCHAMBER_CHATS_DIR, resolve the chats root once on the server and warm it alongside the other bootstrap calls.
This commit is contained in:
@@ -175,7 +175,9 @@ import {
|
||||
buildChatInputHistorySubmissions,
|
||||
buildInputHistoryNavigatorIdentity,
|
||||
mapInputHistoryEntriesToValues,
|
||||
mergeSessionInputHistory,
|
||||
} from './inputHistory';
|
||||
import { useUserMessageHistory } from '@/sync/sync-context';
|
||||
|
||||
// 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.
|
||||
@@ -198,7 +200,6 @@ 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) {
|
||||
@@ -908,13 +909,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
|
||||
),
|
||||
[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 inputHistoryEntries = useInputHistoryStore(React.useCallback(
|
||||
(state) => selectInputHistoryEntries(state, inputHistoryIdentity),
|
||||
[inputHistoryIdentity],
|
||||
));
|
||||
// Session scope also reads the visible transcript, so sessions older than
|
||||
// the persisted history still recall their prompts.
|
||||
const transcriptPrompts = useUserMessageHistory(currentSessionId ?? '');
|
||||
const historyValues = React.useMemo(
|
||||
() => mapInputHistoryEntriesToValues(inputHistoryEntries),
|
||||
[inputHistoryEntries],
|
||||
() => (inputHistoryScope === 'session'
|
||||
? mergeSessionInputHistory(transcriptPrompts, inputHistoryEntries)
|
||||
: mapInputHistoryEntriesToValues(inputHistoryEntries)),
|
||||
[inputHistoryEntries, inputHistoryScope, transcriptPrompts],
|
||||
);
|
||||
const messageHistoryIdentity = React.useMemo(
|
||||
() => buildInputHistoryNavigatorIdentity(inputHistoryScope, inputHistoryIdentity),
|
||||
|
||||
@@ -199,17 +199,22 @@ 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.
|
||||
setting defaults to 40 entries. Recall reads the current session's bucket by
|
||||
default; the Chat setting can widen it to every project on the runtime.
|
||||
- `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.
|
||||
bucket the composer was given. Moving away from a position stores the
|
||||
composer's current text and attachments as an overlay for that position, so
|
||||
the live draft and any edit made to a recalled prompt survive a round trip
|
||||
through history. Overlays never rewrite stored history; sending resets them.
|
||||
- `ChatInput.tsx` applies the recalled text and attachments to the composer and
|
||||
places the caret.
|
||||
|
||||
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.
|
||||
In session scope the composer merges two sources, oldest first: the visible
|
||||
transcript's user prompts (`useUserMessageHistory` in `sync-context.tsx`), so
|
||||
sessions that predate the persisted store still recall, and the persisted
|
||||
session bucket, which adds attachments and keeps prompts a revert hid from the
|
||||
timeline. A prompt present in both collapses to the persisted entry. Global
|
||||
scope reads the persisted runtime bucket only.
|
||||
|
||||
## Mobile
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ export const ComposerEditor = React.forwardRef<ComposerEditorHandle, ComposerEdi
|
||||
// A deferred Enter loses its modifiers in the re-dispatch.
|
||||
if (event.key === 'Enter' && isDeferredSyntheticEvent(event)) {
|
||||
const preserveShift = handlersRef.current.preserveDeferredEnterShift !== false;
|
||||
restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, { preserveShift });
|
||||
restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, preserveShift);
|
||||
clearDeferredEnterModifiers();
|
||||
}
|
||||
return handlersRef.current.onKeyDown?.(event) ?? false;
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('deferred Enter modifiers', () => {
|
||||
for (const modifiers of [{ shiftKey: true, ctrlKey: true, metaKey: false }, { shiftKey: true, ctrlKey: false, metaKey: true }]) {
|
||||
test(`untouched mobile deferred Shift does not submit: ${JSON.stringify(modifiers)}`, () => {
|
||||
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
|
||||
restoreDeferredEnterModifiers(event, modifiers, { preserveShift: true });
|
||||
restoreDeferredEnterModifiers(event, modifiers, true);
|
||||
expect(shouldSubmitEnter(policy({ isMobile: true, ...event }))).toBe(false);
|
||||
});
|
||||
}
|
||||
@@ -75,7 +75,7 @@ describe('deferred Enter modifiers', () => {
|
||||
test('does not restore iOS auto-capitalization as Shift', () => {
|
||||
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
|
||||
|
||||
restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, { preserveShift: false });
|
||||
restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, false);
|
||||
|
||||
expect(event).toEqual({ shiftKey: false, ctrlKey: false, metaKey: false });
|
||||
});
|
||||
|
||||
@@ -28,16 +28,12 @@ export interface EnterModifierState {
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
interface DeferredEnterModifierOptions {
|
||||
preserveShift?: boolean;
|
||||
}
|
||||
|
||||
export const restoreDeferredEnterModifiers = (
|
||||
event: EnterModifierState,
|
||||
modifiers: EnterModifierState,
|
||||
options: DeferredEnterModifierOptions = {},
|
||||
preserveShift = true,
|
||||
): void => {
|
||||
if (options.preserveShift !== false && modifiers.shiftKey) {
|
||||
if (preserveShift && modifiers.shiftKey) {
|
||||
Object.defineProperty(event, 'shiftKey', { value: true });
|
||||
}
|
||||
if (modifiers.ctrlKey) Object.defineProperty(event, 'ctrlKey', { value: true });
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildChatInputHistorySubmissions,
|
||||
buildInputHistoryNavigatorIdentity,
|
||||
mapInputHistoryEntriesToValues,
|
||||
mergeSessionInputHistory,
|
||||
} from './inputHistory';
|
||||
|
||||
const ATTACHMENT: AttachedFile = {
|
||||
@@ -131,3 +132,43 @@ describe('buildInputHistoryNavigatorIdentity', () => {
|
||||
})).toBe('session\nruntime-a\n/repo\nsession-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSessionInputHistory', () => {
|
||||
const entry = (text: string, submittedAtMs: number): InputHistoryEntry => ({
|
||||
text,
|
||||
attachmentKeys: [],
|
||||
restorableAttachments: [],
|
||||
submittedAt: submittedAtMs * 1000,
|
||||
});
|
||||
|
||||
test('recalls transcript prompts when nothing is persisted yet', () => {
|
||||
const values = mergeSessionInputHistory(
|
||||
[{ text: 'first', createdAt: 10 }, { text: 'second', createdAt: 20 }],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(values.map((value) => value.text)).toEqual(['first', 'second']);
|
||||
expect(values[0]?.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
test('interleaves persisted entries by time and collapses duplicates onto the persisted entry', () => {
|
||||
const persisted: InputHistoryEntry = {
|
||||
...entry('second', 20),
|
||||
restorableAttachments: [{
|
||||
key: 'server-file',
|
||||
source: 'file-url',
|
||||
filename: 'server.txt',
|
||||
mimeType: 'text/plain',
|
||||
size: 11,
|
||||
reference: '/repo/server.txt',
|
||||
}],
|
||||
};
|
||||
const values = mergeSessionInputHistory(
|
||||
[{ text: 'first', createdAt: 10 }, { text: 'second', createdAt: 20 }, { text: 'fourth', createdAt: 40 }],
|
||||
[persisted, entry('reverted', 30)],
|
||||
);
|
||||
|
||||
expect(values.map((value) => value.text)).toEqual(['first', 'second', 'reverted', 'fourth']);
|
||||
expect(values[1]?.attachments[0]?.filename).toBe('server.txt');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type InputHistorySubmission,
|
||||
} from '@/stores/useInputHistoryStore';
|
||||
import type { AttachedFile } from '@/stores/types/sessionTypes';
|
||||
import type { TranscriptPrompt } from '@/sync/user-message-history';
|
||||
import { toServerFileUrl } from './composer/attachments/filePaths';
|
||||
|
||||
type HistoryQueuedMessage = {
|
||||
content: string;
|
||||
@@ -22,30 +24,6 @@ type BuildHistorySubmissionsArgs = {
|
||||
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,
|
||||
@@ -85,7 +63,7 @@ function materializeHistoryAttachment(attachment: InputHistoryAttachment): Attac
|
||||
return {
|
||||
id: `history-${attachment.key}`,
|
||||
file: new File([], attachment.filename, { type: attachment.mimeType }),
|
||||
dataUrl: toFileUrl(attachment.reference),
|
||||
dataUrl: toServerFileUrl(attachment.reference),
|
||||
mimeType: attachment.mimeType,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
@@ -109,6 +87,28 @@ export function mapInputHistoryEntriesToValues(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Session-scoped recall: the visible transcript's prompts (so sessions older
|
||||
* than the persisted store still recall) merged with the persisted bucket
|
||||
* (attachments, and prompts a revert hid from the transcript), oldest first.
|
||||
* A prompt present in both collapses to the persisted entry.
|
||||
*/
|
||||
export function mergeSessionInputHistory(
|
||||
transcript: readonly TranscriptPrompt[],
|
||||
entries: readonly InputHistoryEntry[],
|
||||
): Array<MessageHistoryValue<AttachedFile>> {
|
||||
const persistedTexts = new Set(entries.map((entry) => entry.text));
|
||||
const timed: Array<{ at: number; value: MessageHistoryValue<AttachedFile> }> = [
|
||||
...transcript
|
||||
.filter((prompt) => !persistedTexts.has(prompt.text))
|
||||
.map((prompt) => ({ at: prompt.createdAt, value: { text: prompt.text, attachments: [] } })),
|
||||
...mapInputHistoryEntriesToValues(entries)
|
||||
// submittedAt is milliseconds × 1000 plus a sequence number.
|
||||
.map((value, index) => ({ at: Math.floor(entries[index]!.submittedAt / 1000), value })),
|
||||
];
|
||||
return timed.sort((left, right) => left.at - right.at).map((item) => item.value);
|
||||
}
|
||||
|
||||
export function buildInputHistoryNavigatorIdentity(
|
||||
scope: InputHistoryScope,
|
||||
identity: InputHistoryIdentity | null,
|
||||
|
||||
Reference in New Issue
Block a user