Add configurable welcome screen recent chats, fix pin loss on rename, and add popup number input validation (#5436) * Add configurable recent chats settings and fix pin loss on chat rename - Add gear icon button to welcome screen header for settings - Settings popup with Max recent chats (default: 15) and Collapsed recent chats (default: 3) options - Settings stored in accountStorage as 'recentChatsSettings' - Add PinnedChatsManager.rename() to migrate pin state when chats are renamed - Call rename() from both character and group chat rename functions Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/99fa84c4-a82b-4253-96ff-a0621fb63f5c Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Fix pin rename: use file_name with .jsonl extension to match stored pin keys The data-file attribute contains chat_name (without .jsonl), but PinnedChatsManager stores keys using file_name (with .jsonl). Append .jsonl to both old and new names when calling PinnedChatsManager.rename() in character and group chat rename functions. Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/c8e4f720-1d0b-49ef-b57b-ba9d85c42d4f Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Refactor to support number inputs * Restore package-lock * Use clamp() from utils.js instead of manual Math.min/Math.max Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/1669884c-1bd3-4b60-808d-5610f24838e6 Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Revert unintended package-lock.json changes Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/1669884c-1bd3-4b60-808d-5610f24838e6 Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Use input values instead of magic numbers * Add auto-clamping on number inputs in popups and range placeholders in welcome-screen settings Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/9af8ad07-77bc-46db-9ec6-0030fecf0f8d Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Use constants for min/max chat limits to avoid hardcoded duplication in tooltips Agent-Logs-Url: https://github.com/SillyTavern/SillyTavern/sessions/9af8ad07-77bc-46db-9ec6-0030fecf0f8d Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com> * Improve input validation by using Number.isFinite for min/max values in Popup class --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Cohee1207 <18619528+Cohee1207@users.noreply.github.com>

ff10249ab5ef7accac43815ae24b67877b3ce008

Copilot <198982749+Copilot@users.noreply.github.com>

Signed
4 files changed, +170 -10Ignore whitespace
public/css/welcome.css+1 -0
@@ -17,6 +17,7 @@
17.welcomePanel.recentHidden .welcomeRecent,17.welcomePanel.recentHidden .welcomeRecent,
18.welcomePanel.recentHidden .recentChatsTitle,18.welcomePanel.recentHidden .recentChatsTitle,
19.welcomePanel.recentHidden .hideRecentChats,19.welcomePanel.recentHidden .hideRecentChats,
20.welcomePanel.recentHidden .recentChatsSettings,
20.welcomePanel:not(.recentHidden) .showRecentChats {21.welcomePanel:not(.recentHidden) .showRecentChats {
21 display: none;22 display: none;
22}23}
public/scripts/popup.js+45 -4
@@ -2,7 +2,7 @@ import dialogPolyfill from '../lib/dialog-polyfill.esm.js';
2import { shouldSendOnEnter } from './RossAscends-mods.js';2import { shouldSendOnEnter } from './RossAscends-mods.js';
3import { t } from './i18n.js';3import { t } from './i18n.js';
4import { power_user, toastPositionClasses } from './power-user.js';4import { power_user, toastPositionClasses } from './power-user.js';
5import { removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';5import { clamp, removeFromArray, runAfterAnimation, uuidv4 } from './utils.js';
66
7/** @readonly */7/** @readonly */
8/** @enum {Number} */8/** @enum {Number} */
@@ -79,8 +79,11 @@ export const POPUP_RESULT = {
79 * @property {string} label - The label text for the input79 * @property {string} label - The label text for the input
80 * @property {string?} [tooltip=null] - Optional tooltip to be displayed. Default placeholder in input controls, tooltip icon behind the checkbox for those.80 * @property {string?} [tooltip=null] - Optional tooltip to be displayed. Default placeholder in input controls, tooltip icon behind the checkbox for those.
81 * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)81 * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set)
82 * @property {('checkbox'|'text'|'textarea')?} [type='checkbox'] - The type of the input (default is checkbox)82 * @property {('checkbox'|'text'|'textarea'|'number')?} [type='checkbox'] - The type of the input (default is checkbox)
83 * @property {number?} [rows=1] - The number of rows for the input field, if the input is 'textarea'83 * @property {number?} [rows=1] - The number of rows for the input field, if the input is 'textarea'
84 * @property {number?} [min] - The minimum value for number inputs
85 * @property {number?} [max] - The maximum value for number inputs
86 * @property {number?} [step] - The step value for number inputs
84 */87 */
8588
86/**89/**
@@ -387,8 +390,46 @@ export class Popup {
387 label.appendChild(inputElement);390 label.appendChild(inputElement);
388391
389 this.inputControls.appendChild(label);392 this.inputControls.appendChild(label);
393 } else if (input.type === 'number') {
394 const label = document.createElement('label');
395 label.classList.add('text_label', 'justifyCenter');
396 label.setAttribute('for', input.id);
397
398 const inputElement = document.createElement('input');
399 inputElement.classList.add('text_pole', 'result-control');
400 inputElement.type = 'number';
401 inputElement.id = input.id;
402 inputElement.value = String(input.defaultState ?? '');
403 inputElement.placeholder = input.tooltip ?? '';
404 inputElement.min = String(input.min ?? '');
405 inputElement.max = String(input.max ?? '');
406 inputElement.step = String(input.step ?? '');
407 setTitleFromTooltip(inputElement, input.tooltip);
408
409 inputElement.addEventListener('change', () => {
410 const value = parseFloat(inputElement.value);
411 if (isNaN(value)) return;
412
413 const min = Number.isFinite(input.min) ? input.min : -Infinity;
414 const max = Number.isFinite(input.max) ? input.max : Infinity;
415 const clamped = clamp(value, min, max);
416
417 if (clamped !== value) {
418 inputElement.value = String(clamped);
419 toastr.warning(t`Value must be between ${min} and ${max}. Clamped to ${clamped}.`);
420 }
421 });
422
423 const labelText = document.createElement('span');
424 labelText.innerText = input.label;
425 labelText.dataset.i18n = input.label;
426
427 label.appendChild(labelText);
428 label.appendChild(inputElement);
429
430 this.inputControls.appendChild(label);
390 } else {431 } else {
391 console.warn('Unknown custom input type. Only checkbox, text and textarea are supported.', input);432 console.warn('Unknown custom input type. Only checkbox, text, number and textarea are supported.', input);
392 return;433 return;
393 }434 }
394 });435 });
@@ -720,7 +761,7 @@ export class Popup {
720 this.inputResults = new Map(this.customInputs.map(input => {761 this.inputResults = new Map(this.customInputs.map(input => {
721 /** @type {HTMLInputElement} */762 /** @type {HTMLInputElement} */
722 const inputControl = this.dlg.querySelector(`#${input.id}`);763 const inputControl = this.dlg.querySelector(`#${input.id}`);
723 const value = ['text', 'textarea'].includes(input.type) ? inputControl.value : inputControl.checked;764 const value = ['text', 'textarea', 'number'].includes(input.type) ? inputControl.value : inputControl.checked;
724 return [inputControl.id, value];765 return [inputControl.id, value];
725 }));766 }));
726 }767 }
public/scripts/templates/welcomePanel.html+3 -0
@@ -5,6 +5,9 @@
5 <div class="mes_button showRecentChats" title="Show recent chats" data-i18n="[title]Show recent chats">5 <div class="mes_button showRecentChats" title="Show recent chats" data-i18n="[title]Show recent chats">
6 <i class="fa-solid fa-circle-chevron-down fa-fw fa-lg"></i>6 <i class="fa-solid fa-circle-chevron-down fa-fw fa-lg"></i>
7 </div>7 </div>
8 <div class="mes_button recentChatsSettings" title="Recent chats settings" data-i18n="[title]Recent chats settings">
9 <i class="fa-solid fa-gear fa-fw fa-lg"></i>
10 </div>
8 <div class="mes_button hideRecentChats" title="Hide recent chats" data-i18n="[title]Hide recent chats">11 <div class="mes_button hideRecentChats" title="Hide recent chats" data-i18n="[title]Hide recent chats">
9 <i class="fa-solid fa-circle-xmark fa-fw fa-lg"></i>12 <i class="fa-solid fa-circle-xmark fa-fw fa-lg"></i>
10 </div>13 </div>
public/scripts/welcome-screen.js+121 -6
@@ -35,14 +35,43 @@ import { callGenericPopup, POPUP_TYPE } from './popup.js';
35import { getMessageTimeStamp } from './RossAscends-mods.js';35import { getMessageTimeStamp } from './RossAscends-mods.js';
36import { renderTemplateAsync } from './templates.js';36import { renderTemplateAsync } from './templates.js';
37import { accountStorage } from './util/AccountStorage.js';37import { accountStorage } from './util/AccountStorage.js';
38import { flashHighlight, isElementInViewport, sortMoments, timestampToMoment } from './utils.js';38import { clamp, flashHighlight, isElementInViewport, sortMoments, timestampToMoment } from './utils.js';
3939
40const assistantAvatarKey = 'assistant';40const assistantAvatarKey = 'assistant';
41const pinnedChatsKey = 'pinnedChats';41const pinnedChatsKey = 'pinnedChats';
42const recentChatsSettingsKey = 'recentChatsSettings';
42const defaultAssistantAvatar = 'default_Assistant.png';43const defaultAssistantAvatar = 'default_Assistant.png';
4344
44const DEFAULT_DISPLAYED = 3;45const DEFAULT_MAX_DISPLAYED = 15;
45const MAX_DISPLAYED = 15;46const DEFAULT_COLLAPSED_DISPLAYED = 3;
47
48/**
49 * Gets the current recent chats settings from account storage.
50 * @returns {{ maxDisplayed: number, collapsedDisplayed: number }}
51 */
52function getRecentChatsSettings() {
53 const value = accountStorage.getItem(recentChatsSettingsKey);
54 if (value) {
55 try {
56 const parsed = JSON.parse(value);
57 return {
58 maxDisplayed: Math.max(1, parseInt(parsed.maxDisplayed) || DEFAULT_MAX_DISPLAYED),
59 collapsedDisplayed: Math.max(1, parseInt(parsed.collapsedDisplayed) || DEFAULT_COLLAPSED_DISPLAYED),
60 };
61 } catch {
62 // Ignore parse errors
63 }
64 }
65 return { maxDisplayed: DEFAULT_MAX_DISPLAYED, collapsedDisplayed: DEFAULT_COLLAPSED_DISPLAYED };
66}
67
68/**
69 * Saves recent chats settings to account storage.
70 * @param {{ maxDisplayed: number, collapsedDisplayed: number }} settings
71 */
72function saveRecentChatsSettings(settings) {
73 accountStorage.setItem(recentChatsSettingsKey, JSON.stringify(settings));
74}
4675
47/**76/**
48 * @typedef {Pick<RecentChat, 'group' | 'avatar' | 'file_name'>} PinnedChat77 * @typedef {Pick<RecentChat, 'group' | 'avatar' | 'file_name'>} PinnedChat
@@ -82,7 +111,7 @@ class PinnedChatsManager {
82111
83 /**112 /**
84 * Generates a key for pinned chat storage.113 * Generates a key for pinned chat storage.
85 * @param {RecentChat} recentChat Recent chat data114 * @param {Partial<RecentChat>} recentChat Recent chat data
86 * @returns {string} Key for pinned chat storage115 * @returns {string} Key for pinned chat storage
87 */116 */
88 static getKey(recentChat) {117 static getKey(recentChat) {
@@ -141,6 +170,28 @@ class PinnedChatsManager {
141 }170 }
142171
143 /**172 /**
173 * Migrates pinned state when a chat is renamed.
174 * @param {Partial<RecentChat>} recentChat Recent chat data (with original file_name)
175 * @param {string} newFileName New file name after rename
176 */
177 static rename(recentChat, newFileName) {
178 const oldKey = this.getKey(recentChat);
179 const pinState = { ...this.getState() };
180 if (!(oldKey in pinState)) {
181 return;
182 }
183 const updatedChat = { ...recentChat, file_name: newFileName };
184 const newKey = this.getKey(updatedChat);
185 pinState[newKey] = {
186 group: recentChat.group,
187 avatar: recentChat.avatar,
188 file_name: newFileName,
189 };
190 delete pinState[oldKey];
191 this.#saveState(pinState);
192 }
193
194 /**
144 * Gets all pinned chats.195 * Gets all pinned chats.
145 * @returns {PinnedChat[]}196 * @returns {PinnedChat[]}
146 */197 */
@@ -295,6 +346,12 @@ async function sendWelcomePanel(chats, expand = false) {
295 accountStorage.setItem(recentHiddenKey, 'true');346 accountStorage.setItem(recentHiddenKey, 'true');
296 });347 });
297 });348 });
349 root.querySelectorAll('.recentChatsSettings').forEach((button) => {
350 button.addEventListener('click', async (event) => {
351 event.stopPropagation();
352 await openRecentChatsSettingsPopup();
353 });
354 });
298 });355 });
299 fragment.querySelectorAll('.recentChat').forEach((item) => {356 fragment.querySelectorAll('.recentChat').forEach((item) => {
300 item.addEventListener('click', () => {357 item.addEventListener('click', () => {
@@ -493,6 +550,7 @@ async function renameRecentCharacterChat(avatarId, fileName) {
493 newFileName: newName,550 newFileName: newName,
494 loader: false,551 loader: false,
495 });552 });
553 PinnedChatsManager.rename({ avatar: avatarId, group: '', file_name: fileName }, newName);
496 await updateRemoteChatName(characterId, newName);554 await updateRemoteChatName(characterId, newName);
497 await refreshWelcomeScreen();555 await refreshWelcomeScreen();
498 toastr.success(t`Chat renamed.`);556 toastr.success(t`Chat renamed.`);
@@ -526,6 +584,7 @@ async function renameRecentGroupChat(groupId, fileName) {
526 newFileName: String(newName),584 newFileName: String(newName),
527 loader: false,585 loader: false,
528 });586 });
587 PinnedChatsManager.rename({ avatar: '', group: groupId, file_name: fileName }, String(newName));
529 await refreshWelcomeScreen();588 await refreshWelcomeScreen();
530 toastr.success(t`Group chat renamed.`);589 toastr.success(t`Group chat renamed.`);
531 } catch (error) {590 } catch (error) {
@@ -628,6 +687,61 @@ async function refreshWelcomeScreen({ flashChat = null } = {}) {
628}687}
629688
630/**689/**
690 * Opens a popup to configure recent chats settings.
691 */
692async function openRecentChatsSettingsPopup() {
693 const settings = getRecentChatsSettings();
694
695 const MIN_CHATS = 1;
696 const MAX_CHATS = 1000;
697
698 /** @type {import('./popup.js').CustomPopupInput} */
699 const maxRecentChatsInput = {
700 id: 'maxRecentChats',
701 type: 'number',
702 label: t`Max recent chats`,
703 tooltip: t`${MIN_CHATS} - ${MAX_CHATS}`,
704 defaultState: String(settings.maxDisplayed),
705 min: MIN_CHATS,
706 max: MAX_CHATS,
707 step: 1,
708 };
709
710 /** @type {import('./popup.js').CustomPopupInput} */
711 const collapsedRecentChatsInput = {
712 id: 'collapsedRecentChats',
713 type: 'number',
714 label: t`Collapsed recent chats`,
715 tooltip: t`${MIN_CHATS} - ${MAX_CHATS}`,
716 defaultState: String(settings.collapsedDisplayed),
717 min: MIN_CHATS,
718 max: MAX_CHATS,
719 step: 1,
720 };
721
722 await callGenericPopup(t`Recent Chats Settings`, POPUP_TYPE.CONFIRM, null, {
723 okButton: t`Save`,
724 cancelButton: t`Cancel`,
725 customInputs: [maxRecentChatsInput, collapsedRecentChatsInput],
726 onClose: (popup) => {
727 if (!popup.result) {
728 return;
729 }
730
731 const maxInputValue = popup.inputResults.get(maxRecentChatsInput.id)?.toString() ?? String(DEFAULT_MAX_DISPLAYED);
732 const collapsedInputValue = popup.inputResults.get(collapsedRecentChatsInput.id)?.toString() ?? String(DEFAULT_COLLAPSED_DISPLAYED);
733
734 const newMax = clamp(parseInt(maxInputValue) || DEFAULT_MAX_DISPLAYED, maxRecentChatsInput.min, maxRecentChatsInput.max);
735 const newCollapsed = clamp(parseInt(collapsedInputValue) || DEFAULT_COLLAPSED_DISPLAYED, collapsedRecentChatsInput.min, newMax);
736
737 saveRecentChatsSettings({ maxDisplayed: newMax, collapsedDisplayed: newCollapsed });
738 },
739 });
740
741 await refreshWelcomeScreen();
742}
743
744/**
631 * Gets the list of recent chats from the server.745 * Gets the list of recent chats from the server.
632 * @returns {Promise<RecentChat[]>} List of recent chats746 * @returns {Promise<RecentChat[]>} List of recent chats
633 *747 *
@@ -649,10 +763,11 @@ async function refreshWelcomeScreen({ flashChat = null } = {}) {
649 * @property {boolean} pinned Indicates if the chat is pinned763 * @property {boolean} pinned Indicates if the chat is pinned
650 */764 */
651async function getRecentChats() {765async function getRecentChats() {
766 const settings = getRecentChatsSettings();
652 const response = await fetch('/api/chats/recent', {767 const response = await fetch('/api/chats/recent', {
653 method: 'POST',768 method: 'POST',
654 headers: getRequestHeaders(),769 headers: getRequestHeaders(),
655 body: JSON.stringify({ max: MAX_DISPLAYED, pinned: PinnedChatsManager.getAll() }),770 body: JSON.stringify({ max: settings.maxDisplayed, pinned: PinnedChatsManager.getAll() }),
656 cache: 'no-cache',771 cache: 'no-cache',
657 });772 });
658773
@@ -694,7 +809,7 @@ async function getRecentChats() {
694 chat.chat_name = chat.file_name.replace('.jsonl', '');809 chat.chat_name = chat.file_name.replace('.jsonl', '');
695 chat.char_thumbnail = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;810 chat.char_thumbnail = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar;
696 chat.is_group = !!group;811 chat.is_group = !!group;
697 chat.hidden = index >= DEFAULT_DISPLAYED;812 chat.hidden = index >= settings.collapsedDisplayed;
698 chat.avatar = chat.avatar || '';813 chat.avatar = chat.avatar || '';
699 chat.group = chat.group || '';814 chat.group = chat.group || '';
700 chat.pinned = PinnedChatsManager.isPinned(chat);815 chat.pinned = PinnedChatsManager.isPinned(chat);