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>
Signed| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | .welcomePanel.recentHidden .welcomeRecent, |
| 18 | 18 | .welcomePanel.recentHidden .recentChatsTitle, |
| 19 | 19 | .welcomePanel.recentHidden .hideRecentChats, |
| 20 | +.welcomePanel.recentHidden .recentChatsSettings, | |
| 20 | 21 | .welcomePanel:not(.recentHidden) .showRecentChats { |
| 21 | 22 | display: none; |
| 22 | 23 | } |
| @@ -2,7 +2,7 @@ import dialogPolyfill from '../lib/dialog-polyfill.esm.js'; | ||
| 2 | 2 | import { shouldSendOnEnter } from './RossAscends-mods.js'; |
| 3 | 3 | import { t } from './i18n.js'; |
| 4 | 4 | import { power_user, toastPositionClasses } from './power-user.js'; |
| 5 | 5 | import { clamp, removeFromArray, runAfterAnimation, uuidv4 } from './utils.js'; |
| 6 | 6 | |
| 7 | 7 | /** @readonly */ |
| 8 | 8 | /** @enum {Number} */ |
| @@ -79,8 +79,11 @@ export const POPUP_RESULT = { | ||
| 79 | 79 | * @property {string} label - The label text for the input |
| 80 | 80 | * @property {string?} [tooltip=null] - Optional tooltip to be displayed. Default placeholder in input controls, tooltip icon behind the checkbox for those. |
| 81 | 81 | * @property {boolean|string|undefined} [defaultState=false] - The default state when opening the popup (false if not set) |
| 82 | 82 | * @property {('checkbox'|'text'|'textarea'|'number')?} [type='checkbox'] - The type of the input (default is checkbox) |
| 83 | 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 | */ |
| 85 | 88 | |
| 86 | 89 | /** |
| @@ -387,8 +390,46 @@ export class Popup { | ||
| 387 | 390 | label.appendChild(inputElement); |
| 388 | 391 | |
| 389 | 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 | 431 | } else { |
| 391 | 432 | console.warn('Unknown custom input type. Only checkbox, text, number and textarea are supported.', input); |
| 392 | 433 | return; |
| 393 | 434 | } |
| 394 | 435 | }); |
| @@ -720,7 +761,7 @@ export class Popup { | ||
| 720 | 761 | this.inputResults = new Map(this.customInputs.map(input => { |
| 721 | 762 | /** @type {HTMLInputElement} */ |
| 722 | 763 | const inputControl = this.dlg.querySelector(`#${input.id}`); |
| 723 | 764 | const value = ['text', 'textarea', 'number'].includes(input.type) ? inputControl.value : inputControl.checked; |
| 724 | 765 | return [inputControl.id, value]; |
| 725 | 766 | })); |
| 726 | 767 | } |
| @@ -5,6 +5,9 @@ | ||
| 5 | 5 | <div class="mes_button showRecentChats" title="Show recent chats" data-i18n="[title]Show recent chats"> |
| 6 | 6 | <i class="fa-solid fa-circle-chevron-down fa-fw fa-lg"></i> |
| 7 | 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 | 11 | <div class="mes_button hideRecentChats" title="Hide recent chats" data-i18n="[title]Hide recent chats"> |
| 9 | 12 | <i class="fa-solid fa-circle-xmark fa-fw fa-lg"></i> |
| 10 | 13 | </div> |
| @@ -35,14 +35,43 @@ import { callGenericPopup, POPUP_TYPE } from './popup.js'; | ||
| 35 | 35 | import { getMessageTimeStamp } from './RossAscends-mods.js'; |
| 36 | 36 | import { renderTemplateAsync } from './templates.js'; |
| 37 | 37 | import { accountStorage } from './util/AccountStorage.js'; |
| 38 | 38 | import { clamp, flashHighlight, isElementInViewport, sortMoments, timestampToMoment } from './utils.js'; |
| 39 | 39 | |
| 40 | 40 | const assistantAvatarKey = 'assistant'; |
| 41 | 41 | const pinnedChatsKey = 'pinnedChats'; |
| 42 | +const recentChatsSettingsKey = 'recentChatsSettings'; | |
| 42 | 43 | const defaultAssistantAvatar = 'default_Assistant.png'; |
| 43 | 44 | |
| 44 | 45 | const DEFAULT_DISPLAYEDDEFAULT_MAX_DISPLAYED = 315; |
| 45 | 46 | const MAX_DISPLAYEDDEFAULT_COLLAPSED_DISPLAYED = 153; |
| 47 | + | |
| 48 | +/** | |
| 49 | + * Gets the current recent chats settings from account storage. | |
| 50 | + * @returns {{ maxDisplayed: number, collapsedDisplayed: number }} | |
| 51 | + */ | |
| 52 | +function 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 | + */ | |
| 72 | +function saveRecentChatsSettings(settings) { | |
| 73 | + accountStorage.setItem(recentChatsSettingsKey, JSON.stringify(settings)); | |
| 74 | +} | |
| 46 | 75 | |
| 47 | 76 | /** |
| 48 | 77 | * @typedef {Pick<RecentChat, 'group' | 'avatar' | 'file_name'>} PinnedChat |
| @@ -82,7 +111,7 @@ class PinnedChatsManager { | ||
| 82 | 111 | |
| 83 | 112 | /** |
| 84 | 113 | * Generates a key for pinned chat storage. |
| 85 | 114 | * @param {Partial<RecentChat>} recentChat Recent chat data |
| 86 | 115 | * @returns {string} Key for pinned chat storage |
| 87 | 116 | */ |
| 88 | 117 | static getKey(recentChat) { |
| @@ -141,6 +170,28 @@ class PinnedChatsManager { | ||
| 141 | 170 | } |
| 142 | 171 | |
| 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 | 195 | * Gets all pinned chats. |
| 145 | 196 | * @returns {PinnedChat[]} |
| 146 | 197 | */ |
| @@ -295,6 +346,12 @@ async function sendWelcomePanel(chats, expand = false) { | ||
| 295 | 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 | 356 | fragment.querySelectorAll('.recentChat').forEach((item) => { |
| 300 | 357 | item.addEventListener('click', () => { |
| @@ -493,6 +550,7 @@ async function renameRecentCharacterChat(avatarId, fileName) { | ||
| 493 | 550 | newFileName: newName, |
| 494 | 551 | loader: false, |
| 495 | 552 | }); |
| 553 | + PinnedChatsManager.rename({ avatar: avatarId, group: '', file_name: fileName }, newName); | |
| 496 | 554 | await updateRemoteChatName(characterId, newName); |
| 497 | 555 | await refreshWelcomeScreen(); |
| 498 | 556 | toastr.success(t`Chat renamed.`); |
| @@ -526,6 +584,7 @@ async function renameRecentGroupChat(groupId, fileName) { | ||
| 526 | 584 | newFileName: String(newName), |
| 527 | 585 | loader: false, |
| 528 | 586 | }); |
| 587 | + PinnedChatsManager.rename({ avatar: '', group: groupId, file_name: fileName }, String(newName)); | |
| 529 | 588 | await refreshWelcomeScreen(); |
| 530 | 589 | toastr.success(t`Group chat renamed.`); |
| 531 | 590 | } catch (error) { |
| @@ -628,6 +687,61 @@ async function refreshWelcomeScreen({ flashChat = null } = {}) { | ||
| 628 | 687 | } |
| 629 | 688 | |
| 630 | 689 | /** |
| 690 | + * Opens a popup to configure recent chats settings. | |
| 691 | + */ | |
| 692 | +async 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 | 745 | * Gets the list of recent chats from the server. |
| 632 | 746 | * @returns {Promise<RecentChat[]>} List of recent chats |
| 633 | 747 | * |
| @@ -649,10 +763,11 @@ async function refreshWelcomeScreen({ flashChat = null } = {}) { | ||
| 649 | 763 | * @property {boolean} pinned Indicates if the chat is pinned |
| 650 | 764 | */ |
| 651 | 765 | async function getRecentChats() { |
| 766 | + const settings = getRecentChatsSettings(); | |
| 652 | 767 | const response = await fetch('/api/chats/recent', { |
| 653 | 768 | method: 'POST', |
| 654 | 769 | headers: getRequestHeaders(), |
| 655 | 770 | body: JSON.stringify({ max: MAX_DISPLAYEDsettings.maxDisplayed, pinned: PinnedChatsManager.getAll() }), |
| 656 | 771 | cache: 'no-cache', |
| 657 | 772 | }); |
| 658 | 773 | |
| @@ -694,7 +809,7 @@ async function getRecentChats() { | ||
| 694 | 809 | chat.chat_name = chat.file_name.replace('.jsonl', ''); |
| 695 | 810 | chat.char_thumbnail = character ? getThumbnailUrl('avatar', character.avatar) : system_avatar; |
| 696 | 811 | chat.is_group = !!group; |
| 697 | 812 | chat.hidden = index >= DEFAULT_DISPLAYEDsettings.collapsedDisplayed; |
| 698 | 813 | chat.avatar = chat.avatar || ''; |
| 699 | 814 | chat.group = chat.group || ''; |
| 700 | 815 | chat.pinned = PinnedChatsManager.isPinned(chat); |