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
@@ -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');
});
});