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
@@ -0,0 +1,14 @@
|
||||
export const DEFAULT_INPUT_HISTORY_SCOPE = 'global';
|
||||
export const DEFAULT_INPUT_HISTORY_LIMIT = 40;
|
||||
const MIN_INPUT_HISTORY_LIMIT = 1;
|
||||
const MAX_INPUT_HISTORY_LIMIT = 100;
|
||||
|
||||
export const isInputHistoryScope = (value) => (
|
||||
value === 'global' || value === 'session'
|
||||
);
|
||||
|
||||
export const isInputHistoryLimit = (value) => (
|
||||
Number.isInteger(value)
|
||||
&& value >= MIN_INPUT_HISTORY_LIMIT
|
||||
&& value <= MAX_INPUT_HISTORY_LIMIT
|
||||
);
|
||||
@@ -1,4 +1,10 @@
|
||||
import { isAgentMemoryFeatureAvailable } from '../agent-memory/feature-flag.js';
|
||||
import {
|
||||
DEFAULT_INPUT_HISTORY_LIMIT,
|
||||
DEFAULT_INPUT_HISTORY_SCOPE,
|
||||
isInputHistoryLimit,
|
||||
isInputHistoryScope,
|
||||
} from './input-history-scope.js';
|
||||
|
||||
export const createSettingsHelpers = (dependencies) => {
|
||||
const {
|
||||
@@ -140,6 +146,12 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
if (typeof candidate.themeVariant === 'string' && (candidate.themeVariant === 'light' || candidate.themeVariant === 'dark')) {
|
||||
result.themeVariant = candidate.themeVariant;
|
||||
}
|
||||
if (typeof candidate.inputHistoryScope === 'string' && isInputHistoryScope(candidate.inputHistoryScope)) {
|
||||
result.inputHistoryScope = candidate.inputHistoryScope;
|
||||
}
|
||||
if (isInputHistoryLimit(candidate.inputHistoryLimit)) {
|
||||
result.inputHistoryLimit = candidate.inputHistoryLimit;
|
||||
}
|
||||
if (typeof candidate.useSystemTheme === 'boolean') {
|
||||
result.useSystemTheme = candidate.useSystemTheme;
|
||||
}
|
||||
@@ -940,6 +952,8 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
const pwaAppName = normalizePwaAppName(settings?.pwaAppName, '');
|
||||
const pwaOrientation = normalizePwaOrientation(settings?.pwaOrientation, 'system');
|
||||
const mobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode, 'native');
|
||||
const inputHistoryScope = sanitized.inputHistoryScope ?? DEFAULT_INPUT_HISTORY_SCOPE;
|
||||
const inputHistoryLimit = sanitized.inputHistoryLimit ?? DEFAULT_INPUT_HISTORY_LIMIT;
|
||||
|
||||
return {
|
||||
...sanitized,
|
||||
@@ -950,6 +964,8 @@ export const createSettingsHelpers = (dependencies) => {
|
||||
...(pwaAppName ? { pwaAppName } : {}),
|
||||
pwaOrientation,
|
||||
mobileKeyboardMode,
|
||||
inputHistoryScope,
|
||||
inputHistoryLimit,
|
||||
securityScopedBookmarks: bookmarks,
|
||||
pinnedDirectories: normalizeStringArray(settings.pinnedDirectories),
|
||||
typographySizes: sanitizeTypographySizesPartial(settings.typographySizes),
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readdirSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DEFAULT_INPUT_HISTORY_LIMIT,
|
||||
} from './input-history-scope.js';
|
||||
import { createSettingsHelpers } from './settings-helpers.js';
|
||||
import { createSettingsNormalizationRuntime } from './settings-normalization-runtime.js';
|
||||
|
||||
const testFilePath = fileURLToPath(import.meta.url);
|
||||
const packagesWebDir = join(dirname(testFilePath), '..', '..', '..');
|
||||
|
||||
const createTestHelpers = () => createSettingsHelpers({
|
||||
normalizePathForPersistence: (value) => value,
|
||||
normalizeDirectoryPath: (value) => value,
|
||||
@@ -58,6 +69,66 @@ const createTestHelpersWithRealSanitizers = () => {
|
||||
};
|
||||
|
||||
describe('settings helpers', () => {
|
||||
it('imports from the packed @openchamber/web tarball without escaping the published package', async () => {
|
||||
const tempRoot = mkdtempSync(join(tmpdir(), 'settings-helpers-pack-'));
|
||||
const packDir = join(tempRoot, 'pack');
|
||||
const extractDir = join(tempRoot, 'extract');
|
||||
|
||||
try {
|
||||
mkdirSync(packDir);
|
||||
mkdirSync(extractDir);
|
||||
execFileSync('npm', ['pack', '--silent', '--pack-destination', packDir], {
|
||||
cwd: packagesWebDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const tarballName = readdirSync(packDir).find((entry) => entry.endsWith('.tgz'));
|
||||
expect(tarballName).toBeTruthy();
|
||||
|
||||
execFileSync('tar', ['-xzf', join(packDir, tarballName), '-C', extractDir], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
const extractedModule = await import(
|
||||
pathToFileURL(join(extractDir, 'package', 'server', 'lib', 'opencode', 'settings-helpers.js')).href
|
||||
);
|
||||
|
||||
const helpers = extractedModule.createSettingsHelpers({
|
||||
normalizePathForPersistence: (value) => value,
|
||||
normalizeDirectoryPath: (value) => value,
|
||||
normalizeTunnelBootstrapTtlMs: (value) => value,
|
||||
normalizeTunnelSessionTtlMs: (value) => value,
|
||||
normalizeTunnelProvider: (value) => value,
|
||||
normalizeTunnelMode: (value) => value,
|
||||
normalizeOptionalPath: (value) => value,
|
||||
normalizeManagedRemoteTunnelHostname: (value) => value,
|
||||
normalizeManagedRemoteTunnelPresets: () => undefined,
|
||||
normalizeManagedRemoteTunnelPresetTokens: () => undefined,
|
||||
sanitizeTypographySizesPartial: () => undefined,
|
||||
normalizeStringArray: (input) => input,
|
||||
sanitizeModelRefs: () => undefined,
|
||||
sanitizeSkillCatalogs: () => undefined,
|
||||
sanitizeProjects: () => undefined,
|
||||
});
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({
|
||||
inputHistoryScope: 'global',
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({
|
||||
inputHistoryScope: 'session',
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({
|
||||
inputHistoryLimit: 40,
|
||||
});
|
||||
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
||||
inputHistoryScope: 'global',
|
||||
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts only booleans for draft starter visibility', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
@@ -151,6 +222,74 @@ describe('settings helpers', () => {
|
||||
expect(helpers.sanitizeSettingsUpdate({ messageStreamTransport: 'websocket' })).toEqual({});
|
||||
});
|
||||
|
||||
it('accepts inputHistoryScope as a persisted shared setting', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'global' })).toEqual({
|
||||
inputHistoryScope: 'global',
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'session' })).toEqual({
|
||||
inputHistoryScope: 'session',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts valid inputHistoryLimit values as a persisted shared setting', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1 })).toEqual({
|
||||
inputHistoryLimit: 1,
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 40 })).toEqual({
|
||||
inputHistoryLimit: 40,
|
||||
});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 100 })).toEqual({
|
||||
inputHistoryLimit: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid inputHistoryLimit values', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 0 })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 101 })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: 1.5 })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: '40' })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NaN })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.POSITIVE_INFINITY })).toEqual({});
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryLimit: Number.NEGATIVE_INFINITY })).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects invalid inputHistoryScope values', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.sanitizeSettingsUpdate({ inputHistoryScope: 'workspace' })).toEqual({});
|
||||
});
|
||||
|
||||
it('defaults inputHistoryScope to global in formatted settings responses', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.formatSettingsResponse({ inputHistoryScope: 'session' })).toMatchObject({
|
||||
inputHistoryScope: 'session',
|
||||
});
|
||||
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
||||
inputHistoryScope: 'global',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults missing inputHistoryLimit to 40 in formatted settings responses and preserves valid values', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
expect(helpers.formatSettingsResponse({})).toMatchObject({
|
||||
inputHistoryLimit: DEFAULT_INPUT_HISTORY_LIMIT,
|
||||
});
|
||||
expect(helpers.formatSettingsResponse({ inputHistoryLimit: 1 })).toMatchObject({
|
||||
inputHistoryLimit: 1,
|
||||
});
|
||||
expect(helpers.formatSettingsResponse({ inputHistoryLimit: 100 })).toMatchObject({
|
||||
inputHistoryLimit: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes the persisted terminal shell', () => {
|
||||
const helpers = createTestHelpers();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user