feat(ui): add interface to manage message swipe history (#5304) * feat(ui): add popup to jump to a specific swipe Adds a new "Jump to swipe history" button to message actions and makes the swipe counter clickable on the latest message. This opens a searchable popup allowing users to quickly find and jump to a specific alternate swipe without having to click through them sequentially. - Adds a searchable swipe picker popup menu - Makes the swipe counter interactive when multiple swipes exist - Adds a dedicated swipe picker button to message controls * fix(ui): hide swipe picker when there are no swipes Ensures the newly added swipe picker button is only shown when a message has multiple swipes available. It explicitly hides the button for non-swipeable messages or messages with a single swipe. * feat(ui): redesign swipe picker with direct id input Replace text-based search with a numeric input for direct swipe navigation. Update popup layout with a sticky header and improved scrolling behavior. Sync input value with the currently selected swipe in the list. Refactor styling to align with chat selection components. * feat(ui): allow branching from specific swipes via picker - Enable swipe picker for historical messages to inspect alternate swipes - Add branch button to picker entries to create new chats from specific swipes - Update saveChat and createBranch to accept chat snapshots - Restrict swipe jumping to the active message only * refactor(logic): consolidate swipe sync logic and simplify helpers Update `syncSwipeToMes` to accept a target message object, enabling its use in the bookmarks module and removing the duplicate `applySwipeToSnapshot` function. Also simplify `canOpenSwipePickerForMessage` and `canJumpToSwipeForMessage` signatures by removing the redundant message parameter. * refactor(a11y): support dynamic roles via classes Introduce a managed role system in the accessibility script to handle elements that dynamically gain or lose interactive states. The mutation observer now watches for class attribute changes and automatically applies or clears roles (e.g., `role="button"`) using active selectors. Updated the swipe counter to rely on this centralized system by toggling an `.interactable` class instead of manually modifying tabindex and role attributes. Removed the redundant 'Enter' keydown handler for the swipe counter to prevent duplicate trigger events. * fix(ui): compute missing token counts in swipe picker Update renderSwipeList to asynchronously calculate token counts when missing from swipe metadata. Introduce SWIPE_SOURCE.SWIPE_PICKER to correctly identify swipes triggered from the picker and bypass generation checks. * feat(ui): enable deleting specific swipes via swipe picker - Adds a delete button to swipe picker entries, allowing removal of specific message versions. - Refactors deletion logic to handle removing non-current swipes without triggering animations and correctly updates indices. - Includes confirmation dialogs and improves input focus behavior. * refactor:Delete process inline to button click processor * feat: universal swipe inspection and picker improvements - Permit opening the swipe browser on any chat entry to review past generations. - Parallelize the retrieval of token statistics to speed up list rendering. - Format message metrics (length and tokens) into a single, concise string. - Update the `getBranchChatSnapshot` API to accept an options object. - Register swipe list items as interactable elements for keyboard control. - Apply styling to prevent text highlighting on picker entries. * fix:remove unused CSS * fix: fix disabled styling for swipe delete button Remove tooltips and prevent hover animations or glow effects when the delete button is disabled in the swipe picker. Update CSS to enforce default cursor and fixed opacity on hover for the disabled state. * remove: Unused CSS * Extract swipe-picker.js module * Revert to manual ARIA role management * Avoid scrollIntoView and scroll on open * Fix keyboard interaction in past chats menu * Fix a11y attribute * fix: call refreshSwipeButtons when deleting not selected swipe --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

b04c9744078105fd26bcc359725c300eca72908c

awaae <108462724+awaae001@users.noreply.github.com>

Signed
8 files changed, +590 -40Ignore whitespace
public/index.html+1 -0
@@ -7365,6 +7365,7 @@
7365 <div title="Toggle media display style" class="mes_button mes_media_gallery fa-solid fa-photo-film" data-i18n="[title]Toggle media display style"></div>7365 <div title="Toggle media display style" class="mes_button mes_media_gallery fa-solid fa-photo-film" data-i18n="[title]Toggle media display style"></div>
7366 <div title="Toggle media display style" class="mes_button mes_media_list fa-solid fa-table-cells-large" data-i18n="[title]Toggle media display style"></div>7366 <div title="Toggle media display style" class="mes_button mes_media_list fa-solid fa-table-cells-large" data-i18n="[title]Toggle media display style"></div>
7367 <div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>7367 <div title="Embed file or image" class="mes_button mes_embed fa-solid fa-paperclip" data-i18n="[title]Embed file or image"></div>
7368 <div title="Jump to swipe history" class="mes_button mes_swipe_picker fa-solid fa-bookmark" data-i18n="[title]Jump to swipe history" style="display: none;"></div>
7368 <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>7369 <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>
7369 <div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>7370 <div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>
7370 <div title="Copy" class="mes_button mes_copy fa-solid fa-copy " data-i18n="[title]Copy"></div>7371 <div title="Copy" class="mes_button mes_copy fa-solid fa-copy " data-i18n="[title]Copy"></div>
public/script.js+75 -25
@@ -286,6 +286,7 @@ import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
286import { addChatBackupsBrowser } from './scripts/chat-backups.js';286import { addChatBackupsBrowser } from './scripts/chat-backups.js';
287import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';287import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
288import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';288import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';
289import { canJumpToSwipeForMessage, canOpenSwipePickerForMessage, initSwipePicker } from './scripts/swipe-picker.js';
289290
290// API OBJECT FOR EXTERNAL WIRING291// API OBJECT FOR EXTERNAL WIRING
291globalThis.SillyTavern = {292globalThis.SillyTavern = {
@@ -776,6 +777,7 @@ async function firstLoadInit() {
776 initDataMaid();777 initDataMaid();
777 initItemizedPrompts();778 initItemizedPrompts();
778 initAccessibility();779 initAccessibility();
780 initSwipePicker();
779 addDebugFunctions();781 addDebugFunctions();
780 doDailyExtensionUpdatesCheck();782 doDailyExtensionUpdatesCheck();
781 await eventSource.emit(event_types.APP_INITIALIZED);783 await eventSource.emit(event_types.APP_INITIALIZED);
@@ -6847,20 +6849,24 @@ export function syncMesToSwipe(messageId = null) {
6847 * If the swipe data is invalid in some way, this function will exit out without doing anything.6849 * If the swipe data is invalid in some way, this function will exit out without doing anything.
6848 * @param {number?} [messageId=null] - The ID of the message to sync with the swipe data. If no ID is given, the last message is used.6850 * @param {number?} [messageId=null] - The ID of the message to sync with the swipe data. If no ID is given, the last message is used.
6849 * @param {number?} [swipeId=null] - The ID of the swipe to sync. If no ID is given, the current swipe ID in the message object is used.6851 * @param {number?} [swipeId=null] - The ID of the swipe to sync. If no ID is given, the current swipe ID in the message object is used.
6852 * @param {ChatMessage?} [targetMessage=null] - The message object to sync instead of resolving one from `chat`.
6850 * @returns {boolean} Whether the swipe data was successfully synced to the message6853 * @returns {boolean} Whether the swipe data was successfully synced to the message
6851 */6854 */
6852export function syncSwipeToMes(messageId = null, swipeId = null) {6855export function syncSwipeToMes(messageId = null, swipeId = null, targetMessage = null) {
6853 if (!chat.length) {6856 if (!targetMessage && !chat.length) {
6854 return false;6857 return false;
6855 }6858 }
68566859
6857 const targetMessageId = messageId ?? chat.length - 1;6860 if (!targetMessage) {
6858 if (targetMessageId >= chat.length || targetMessageId < 0) {6861 const targetMessageId = messageId ?? chat.length - 1;
6859 console.warn(`[syncSwipeToMes] Invalid message ID: ${messageId}`);6862 if (targetMessageId >= chat.length || targetMessageId < 0) {
6860 return false;6863 console.warn(`[syncSwipeToMes] Invalid message ID: ${messageId}`);
6864 return false;
6865 }
6866
6867 targetMessage = chat[targetMessageId];
6861 }6868 }
68626869
6863 const targetMessage = chat[targetMessageId];
6864 if (!targetMessage) {6870 if (!targetMessage) {
6865 return false;6871 return false;
6866 }6872 }
@@ -7283,10 +7289,11 @@ export function saveChatDebounced() {
7283 * @param {object} [options.withMetadata] Additional metadata to save with the chat7289 * @param {object} [options.withMetadata] Additional metadata to save with the chat
7284 * @param {number} [options.mesId] The message ID to save the chat up to7290 * @param {number} [options.mesId] The message ID to save the chat up to
7285 * @param {boolean} [options.force] Force the saving despite the integrity check result7291 * @param {boolean} [options.force] Force the saving despite the integrity check result
7292 * @param {ChatMessage[]} [options.chatData] Chat snapshot to save instead of the current in-memory chat
7286 *7293 *
7287 * @returns {Promise<void>}7294 * @returns {Promise<void>}
7288 */7295 */
7289export async function saveChat({ chatName, withMetadata, mesId, force = false } = {}) {7296export async function saveChat({ chatName, withMetadata, mesId, force = false, chatData = undefined } = {}) {
7290 if (selected_group) {7297 if (selected_group) {
7291 toastr.error(t`Operation was aborted to prevent data corruption.`, t`saveChat called for a group chat`);7298 toastr.error(t`Operation was aborted to prevent data corruption.`, t`saveChat called for a group chat`);
7292 throw new Error('saveChat called for a group chat');7299 throw new Error('saveChat called for a group chat');
@@ -7312,9 +7319,11 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
73127319
7313 characters[this_chid].date_last_chat = Date.now();7320 characters[this_chid].date_last_chat = Date.now();
73147321
7315 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)7322 const trimmedChat = Array.isArray(chatData)
7316 ? chat.slice(0, Number(mesId) + 1)7323 ? chatData
7317 : chat.slice();7324 : (mesId !== undefined && mesId >= 0 && mesId < chat.length)
7325 ? chat.slice(0, Number(mesId) + 1)
7326 : chat.slice();
73187327
7319 /** @type {ChatHeader} */7328 /** @type {ChatHeader} */
7320 const chatHeader = {7329 const chatHeader = {
@@ -9019,7 +9028,22 @@ export async function updateSwipeCounter(mesId, { message = undefined, messageEl
90199028
9020 const swipeCounterText = formatSwipeCounter((message?.swipe_id + 1), message?.swipes?.length);9029 const swipeCounterText = formatSwipeCounter((message?.swipe_id + 1), message?.swipes?.length);
9021 const swipeCounter = messageElement.find('.swipes-counter');9030 const swipeCounter = messageElement.find('.swipes-counter');
9022 swipeCounter.text(swipeCounterText).prop('hidden', false);9031 const swipePickerButton = messageElement.find('.mes_swipe_picker');
9032 const canOpenSwipePicker = canOpenSwipePickerForMessage(mesId);
9033 const canJumpToSwipe = canJumpToSwipeForMessage(mesId);
9034
9035 swipeCounter
9036 .text(swipeCounterText)
9037 .prop('hidden', false)
9038 .toggleClass('swipe-picker-enabled', canOpenSwipePicker)
9039 .toggleClass(INTERACTABLE_CONTROL_CLASS, canOpenSwipePicker)
9040 .attr('role', canOpenSwipePicker ? 'button' : null)
9041 .attr('title', canJumpToSwipe ? t`Click to jump to a swipe` : canOpenSwipePicker ? t`Click to view swipe history` : null);
9042 swipePickerButton.toggle(canOpenSwipePicker);
9043
9044 if (!canOpenSwipePicker) {
9045 swipeCounter.removeAttr('tabindex');
9046 }
9023}9047}
90249048
9025/**9049/**
@@ -9148,6 +9172,8 @@ export function refreshSwipeButtons(updateCounters = false, fade = true) {
9148 const isLastSwipe = (message?.swipes?.length ?? 1) - 1 <= (message?.swipe_id ?? 0);9172 const isLastSwipe = (message?.swipes?.length ?? 1) - 1 <= (message?.swipe_id ?? 0);
9149 const hasSwipes = (message?.swipes?.length > 1);9173 const hasSwipes = (message?.swipes?.length > 1);
9150 const overswipe = getOverswipeBehavior(messageId, message);9174 const overswipe = getOverswipeBehavior(messageId, message);
9175 const swipePickerButton = $(div).find('.mes_swipe_picker');
9176 const canOpenSwipePicker = canOpenSwipePickerForMessage(messageId);
91519177
9152 // Chevrons should always be shown on pristine greetings: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-35578933739178 // Chevrons should always be shown on pristine greetings: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373
9153 const pristineGreeting = overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING;9179 const pristineGreeting = overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING;
@@ -9161,12 +9187,14 @@ export function refreshSwipeButtons(updateCounters = false, fade = true) {
91619187
9162 //If there's only one swipe, the left arrow should not be shown.9188 //If there's only one swipe, the left arrow should not be shown.
9163 div.classList.toggle('swipes_visible', hasSwipes || pristineGreeting);9189 div.classList.toggle('swipes_visible', hasSwipes || pristineGreeting);
9190 swipePickerButton.toggle(canOpenSwipePicker);
91649191
9165 //updateSwipeCounter does not need to be awaited, It can run a bit later.9192 //updateSwipeCounter does not need to be awaited, It can run a bit later.
9166 if (updateCounters) updateSwipeCounter(messageId, { message, messageElement: $(div) });9193 if (updateCounters) updateSwipeCounter(messageId, { message, messageElement: $(div) });
9167 } else {9194 } else {
9168 //Hide all messages that are not swipeable.9195 //Hide all messages that are not swipeable.
9169 div.classList.remove('swipes_visible', 'last_swipe');9196 div.classList.remove('swipes_visible', 'last_swipe');
9197 $(div).find('.mes_swipe_picker').toggle(canOpenSwipePickerForMessage(messageId));
9170 }9198 }
9171 });9199 });
9172}9200}
@@ -9200,9 +9228,12 @@ export function hideSwipeButtons({ hideCounters = false } = {}) {
9200 * @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.9228 * @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.
9201 */9229 */
9202export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {9230export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
9203 if (swipeId && (isNaN(swipeId) || swipeId < 0)) {9231 if (swipeId != null) {
9204 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);9232 swipeId = Number(swipeId);
9205 return;9233 if (!Number.isInteger(swipeId) || swipeId < 0) {
9234 toastr.warning(t`Invalid swipe ID.`);
9235 return;
9236 }
9206 }9237 }
92079238
9208 const message = chat[messageId];9239 const message = chat[messageId];
@@ -9216,7 +9247,8 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
9216 return;9247 return;
9217 }9248 }
92189249
9219 swipeId = swipeId ?? message.swipe_id;9250 swipeId = Number(swipeId ?? message.swipe_id);
9251 const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
92209252
9221 if (swipeId < 0 || swipeId >= message.swipes.length) {9253 if (swipeId < 0 || swipeId >= message.swipes.length) {
9222 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);9254 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
@@ -9229,17 +9261,35 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
9229 message.swipe_info.splice(swipeId, 1);9261 message.swipe_info.splice(swipeId, 1);
9230 }9262 }
92319263
9232 // Select the next swipe, or the one before if it was the last one9264 let newSwipeId;
9233 const newSwipeId = Math.min(swipeId, message.swipes.length - 1);9265 if (swipeId < currentSwipeId) {
9266 newSwipeId = currentSwipeId - 1;
9267 } else if (swipeId > currentSwipeId) {
9268 newSwipeId = currentSwipeId;
9269 } else {
9270 // Select the next swipe, or the one before if it was the last one.
9271 newSwipeId = Math.min(swipeId, message.swipes.length - 1);
9272 }
92349273
9235 chat_metadata.tainted = true;9274 chat_metadata.tainted = true;
92369275
9237 messageId = Number(messageId);9276 messageId = Number(messageId);
9238 swipeId = Number(swipeId);9277 swipeId = Number(swipeId);
9278 message.swipe_id = newSwipeId;
9239 await eventSource.emit(event_types.MESSAGE_SWIPE_DELETED, { messageId, swipeId, newSwipeId });9279 await eventSource.emit(event_types.MESSAGE_SWIPE_DELETED, { messageId, swipeId, newSwipeId });
9240 let direction = (swipeId <= newSwipeId) ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT;9280
9241 //Animate swipe and swap dispayed message.9281 if (swipeId === currentSwipeId) {
9242 await swipe(null, direction, { source: SWIPE_SOURCE.DELETE, repeated: false, forceMesId: messageId, forceSwipeId: newSwipeId });9282 const direction = (swipeId <= newSwipeId) ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT;
9283 // Animate swipe and swap displayed message when the currently visible swipe was deleted.
9284 await swipe(null, direction, { source: SWIPE_SOURCE.DELETE, repeated: false, forceMesId: messageId, forceSwipeId: newSwipeId });
9285 } else {
9286 await updateSwipeCounter(messageId);
9287 if (messageId !== chat.length - 1) {
9288 await updateSwipeCounter(chat.length - 1);
9289 }
9290 refreshSwipeButtons();
9291 saveChatDebounced();
9292 }
92439293
9244 await saveChatConditional();9294 await saveChatConditional();
92459295
@@ -9785,7 +9835,7 @@ function formatSwipeCounter(current, total) {
9785 * @param {SwipeEvent} event Event.9835 * @param {SwipeEvent} event Event.
9786 * @param {SWIPE_DIRECTION} direction The direction to swipe.9836 * @param {SWIPE_DIRECTION} direction The direction to swipe.
9787 * @param {object} params Additional parameters.9837 * @param {object} params Additional parameters.
9788 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.9838 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event.
9789 * @param {boolean} [params.repeated] Is the swipe event repeated.9839 * @param {boolean} [params.repeated] Is the swipe event repeated.
9790 * @param {ChatMessage} [params.message=chat[chat.length - 1]] The chat message to swipe.9840 * @param {ChatMessage} [params.message=chat[chat.length - 1]] The chat message to swipe.
9791 * @param {number} [params.forceMesId] The message id to swipe.9841 * @param {number} [params.forceMesId] The message id to swipe.
@@ -9811,7 +9861,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
98119861
9812 const mesId = Number(forceMesId ?? event?.currentTarget?.closest('.mes')?.getAttribute('mesid') ?? messageIndex ?? chat.length - 1);9862 const mesId = Number(forceMesId ?? event?.currentTarget?.closest('.mes')?.getAttribute('mesid') ?? messageIndex ?? chat.length - 1);
98139863
9814 if ([SWIPE_SOURCE.DELETE, SWIPE_SOURCE.BACK, SWIPE_SOURCE.AUTO_SWIPE, SWIPE_SOURCE.SLASH_COMMAND].includes(source)) {9864 if ([SWIPE_SOURCE.DELETE, SWIPE_SOURCE.BACK, SWIPE_SOURCE.AUTO_SWIPE, SWIPE_SOURCE.SLASH_COMMAND, SWIPE_SOURCE.SWIPE_PICKER].includes(source)) {
9815 console.info(`The ${direction} swipe source on message #${mesId} is ${source}, Most checks have been bypassed. `);9865 console.info(`The ${direction} swipe source on message #${mesId} is ${source}, Most checks have been bypassed. `);
9816 } else {9866 } else {
9817 //Only show an error if swipes are not hidden and a message is generating.9867 //Only show an error if swipes are not hidden and a message is generating.
@@ -10271,7 +10321,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
10271 * Handles the swipe to the left event.10321 * Handles the swipe to the left event.
10272 * @param {SwipeEvent} [event] Event.10322 * @param {SwipeEvent} [event] Event.
10273 * @param {object} params Additional parameters.10323 * @param {object} params Additional parameters.
10274 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.10324 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event.
10275 * @param {boolean} [params.repeated] Is the swipe event repeated.10325 * @param {boolean} [params.repeated] Is the swipe event repeated.
10276 * @param {object} [params.message] The chat message to swipe.10326 * @param {object} [params.message] The chat message to swipe.
10277 */10327 */
@@ -10284,7 +10334,7 @@ export async function swipe_left(event, { source, repeated, message } = {}) {
10284 * Handles the swipe to the right event.10334 * Handles the swipe to the right event.
10285 * @param {SwipeEvent} [event] Event.10335 * @param {SwipeEvent} [event] Event.
10286 * @param {object} params Additional parameters.10336 * @param {object} params Additional parameters.
10287 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.10337 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event.
10288 * @param {boolean} [params.repeated] Is the swipe event repeated.10338 * @param {boolean} [params.repeated] Is the swipe event repeated.
10289 * @param {object} [params.message] The chat message to swipe.10339 * @param {object} [params.message] The chat message to swipe.
10290 */10340 */
public/scripts/bookmarks.js+43 -5
@@ -2,6 +2,7 @@ import {
2 characters,2 characters,
3 saveChat,3 saveChat,
4 system_message_types,4 system_message_types,
5 syncSwipeToMes,
5 this_chid,6 this_chid,
6 openCharacterChat,7 openCharacterChat,
7 chat_metadata,8 chat_metadata,
@@ -161,8 +162,28 @@ async function saveBookmarkMenu() {
161 return await createNewBookmark(chat.length - 1);162 return await createNewBookmark(chat.length - 1);
162}163}
163164
165/**
166 * Builds the branch chat snapshot, optionally selecting a specific swipe for the target message.
167 * @param {number} mesId
168 * @param {{swipeId?: number|null}} [options={}]
169 * @returns {ChatMessage[]|null}
170 */
171function getBranchChatSnapshot(mesId, { swipeId = null } = {}) {
172 const snapshot = structuredClone(chat.slice(0, Number(mesId) + 1));
173
174 if (swipeId === null) {
175 return snapshot;
176 }
177
178 if (!syncSwipeToMes(null, swipeId, snapshot[mesId])) {
179 return null;
180 }
181
182 return snapshot;
183}
184
164// Export is used by Timelines extension. Do not remove.185// Export is used by Timelines extension. Do not remove.
165export async function createBranch(mesId) {186export async function createBranch(mesId, { swipeId = null } = {}) {
166 if (!chat.length) {187 if (!chat.length) {
167 toastr.warning('The chat is empty.', 'Branch creation failed');188 toastr.warning('The chat is empty.', 'Branch creation failed');
168 return;189 return;
@@ -176,6 +197,12 @@ export async function createBranch(mesId) {
176 const lastMes = chat[mesId];197 const lastMes = chat[mesId];
177 const mainChatName = (getCurrentChatDetails()).sessionName;198 const mainChatName = (getCurrentChatDetails()).sessionName;
178 const newMetadata = { main_chat: mainChatName };199 const newMetadata = { main_chat: mainChatName };
200 const selectedSwipeId = swipeId === null ? null : Number(swipeId);
201
202 if (selectedSwipeId !== null && (!Number.isInteger(selectedSwipeId) || selectedSwipeId < 0 || selectedSwipeId >= (lastMes?.swipes?.length ?? 0))) {
203 toastr.warning('Invalid swipe ID.', 'Branch creation failed');
204 return;
205 }
179206
180 function buildBranchName(name, i) {207 function buildBranchName(name, i) {
181 // Strip off existing suffixes, then build new name208 // Strip off existing suffixes, then build new name
@@ -192,10 +219,16 @@ export async function createBranch(mesId) {
192 return;219 return;
193 }220 }
194221
222 const branchChatSnapshot = getBranchChatSnapshot(mesId, { swipeId: selectedSwipeId });
223 if (!branchChatSnapshot) {
224 toastr.warning('Could not prepare the selected swipe for branching.', 'Branch creation failed');
225 return;
226 }
227
195 if (selected_group) {228 if (selected_group) {
196 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId);229 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId, branchChatSnapshot);
197 } else {230 } else {
198 await saveChat({ chatName: name, withMetadata: newMetadata, mesId });231 await saveChat({ chatName: name, withMetadata: newMetadata, mesId, chatData: branchChatSnapshot });
199 }232 }
200 // append to branches list if it exists233 // append to branches list if it exists
201 // otherwise create it234 // otherwise create it
@@ -410,15 +443,20 @@ export async function convertSoloToGroupChat() {
410/**443/**
411 * Creates a new branch from the message with the given ID444 * Creates a new branch from the message with the given ID
412 * @param {number} mesId Message ID445 * @param {number} mesId Message ID
446 * @param {{swipeId?: number|null}} [options={}] Branch options
413 * @returns {Promise<string?>} Branch file name447 * @returns {Promise<string?>} Branch file name
414 */448 */
415export async function branchChat(mesId) {449export async function branchChat(mesId, { swipeId = null } = {}) {
416 if (this_chid === undefined && !selected_group) {450 if (this_chid === undefined && !selected_group) {
417 toastr.info('No character selected.', 'Create Branch');451 toastr.info('No character selected.', 'Create Branch');
418 return null;452 return null;
419 }453 }
420454
421 const fileName = await createBranch(mesId);455 const fileName = await createBranch(mesId, { swipeId });
456 if (!fileName) {
457 return null;
458 }
459
422 await saveItemizedPrompts(fileName);460 await saveItemizedPrompts(fileName);
423461
424 if (selected_group) {462 if (selected_group) {
public/scripts/constants.js+1 -0
@@ -176,6 +176,7 @@ export const SWIPE_SOURCE = {
176 BACK: 'back',176 BACK: 'back',
177 AUTO_SWIPE: 'auto_swipe',177 AUTO_SWIPE: 'auto_swipe',
178 SLASH_COMMAND: 'slash_command',178 SLASH_COMMAND: 'slash_command',
179 SWIPE_PICKER: 'swipe_picker',
179};180};
180181
181/**182/**
public/scripts/group-chats.js+7 -4
@@ -2352,9 +2352,10 @@ export async function importGroupChat(formData, { refresh = true } = {}) {
2352 * @param {string} name Name of the chat to save2352 * @param {string} name Name of the chat to save
2353 * @param {ChatMetadata?} metadata New metadata to save with the chat2353 * @param {ChatMetadata?} metadata New metadata to save with the chat
2354 * @param {number|undefined} mesId Optional message ID to trim the chat up to2354 * @param {number|undefined} mesId Optional message ID to trim the chat up to
2355 * @param {ChatMessage[]|undefined} chatData Optional chat snapshot to save instead of the current in-memory chat
2355 * @returns {Promise<void>} Promise that resolves when the group chat is saved2356 * @returns {Promise<void>} Promise that resolves when the group chat is saved
2356 */2357 */
2357export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {2358export async function saveGroupBookmarkChat(groupId, name, metadata, mesId, chatData = undefined) {
2358 const group = groups.find(x => x.id === groupId);2359 const group = groups.find(x => x.id === groupId);
23592360
2360 if (!group) {2361 if (!group) {
@@ -2371,9 +2372,11 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
2371 };2372 };
23722373
2373 /** @type {ChatMessage[]} */2374 /** @type {ChatMessage[]} */
2374 const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)2375 const trimmedChat = Array.isArray(chatData)
2375 ? chat.slice(0, Number(mesId) + 1)2376 ? chatData
2376 : chat;2377 : (mesId !== undefined && mesId >= 0 && mesId < chat.length)
2378 ? chat.slice(0, Number(mesId) + 1)
2379 : chat;
23772380
2378 await editGroup(groupId, true, false);2381 await editGroup(groupId, true, false);
23792382
public/scripts/keyboard.js+6 -0
@@ -8,6 +8,7 @@ const interactableSelectors = [
8 '.inline-drawer-icon', // Buttons/icons inside the drawer menus8 '.inline-drawer-icon', // Buttons/icons inside the drawer menus
9 '.paginationjs-pages li a', // Pagination buttons9 '.paginationjs-pages li a', // Pagination buttons
10 '.group_select, .character_select, .bogus_folder_select', // Cards to select char, group or folder in character list and other places10 '.group_select, .character_select, .bogus_folder_select', // Cards to select char, group or folder in character list and other places
11 '.swipe_picker_block', // Swipe picker entries in the swipe history popup
11 '.avatar-container', // Persona list blocks12 '.avatar-container', // Persona list blocks
12 '.tag .tag_remove', // Remove button in removable tags13 '.tag .tag_remove', // Remove button in removable tags
13 '.bg_example', // Background elements in the background menu14 '.bg_example', // Background elements in the background menu
@@ -20,6 +21,11 @@ const interactableSelectors = [
20 '.select2_choice_clickable+span.select2-container .select2-selection__choice__display', // select2 control elements if they are meant to be clickable21 '.select2_choice_clickable+span.select2-container .select2-selection__choice__display', // select2 control elements if they are meant to be clickable
21 '.avatar_load_preview', // Char display avatar selection22 '.avatar_load_preview', // Char display avatar selection
22 '.bg_tabs_list .bg_tab_button', // Background tabs23 '.bg_tabs_list .bg_tab_button', // Background tabs
24 '.select_chat_block', // The blocks to select a past chat in the past chats menu
25 '.select_chat_block .exportRawChatButton', // Export raw chat button in the past chats menu
26 '.select_chat_block .exportChatButton', // Export chat button in the past chats menu
27 '.select_chat_block .PastChat_cross', // Delete chat button in the past chats menu
28 '.select_chat_block .renameChatButton', // The button to rename a past chat in the past chats menu
23];29];
2430
25if (CSS.supports('selector(:has(*))')) {31if (CSS.supports('selector(:has(*))')) {
public/scripts/swipe-picker.js+403 -0
@@ -0,0 +1,403 @@
1import { branchChat } from './bookmarks.js';
2import { SWIPE_DIRECTION, SWIPE_SOURCE } from './constants.js';
3import { t } from './i18n.js';
4import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
5import { power_user } from './power-user.js';
6import { getTokenCountAsync } from './tokenizers.js';
7import { clamp, timestampToMoment } from './utils.js';
8import { chat, deleteSwipe, ensureSwipes, isMessageSwipeable, isSwipingAllowed, swipe, syncMesToSwipe } from '/script.js';
9
10/**
11 * Returns whether a swipe picker can be opened for the message.
12 * Unlike message swiping, this supports historical AI messages for inspection and branching.
13 * @param {number} messageId
14 * @returns {boolean}
15 */
16export function canOpenSwipePickerForMessage(messageId) {
17 const message = chat[messageId];
18
19 if (!message) {
20 return false;
21 }
22
23 if (ensureSwipes(message)) {
24 syncMesToSwipe(messageId);
25 }
26
27 return Boolean(
28 message?.swipes?.length > 1 &&
29 !message?.is_user &&
30 !(message?.extra?.isSmallSys) &&
31 !(message?.extra?.swipeable === false),
32 );
33}
34
35/**
36 * Returns whether the picker can actively jump to a different swipe.
37 * Historical AI messages can open the picker, but only the currently swipeable message may jump.
38 * @param {number} messageId
39 * @returns {boolean}
40 */
41export function canJumpToSwipeForMessage(messageId) {
42 const message = chat[messageId];
43 return canOpenSwipePickerForMessage(messageId) && isSwipingAllowed() && isMessageSwipeable(messageId, message);
44}
45
46/**
47 * Opens a popup for viewing or jumping to a specific swipe on a message.
48 * @param {number} messageId
49 * @returns {Promise<void>}
50 */
51async function openSwipePicker(messageId) {
52 const message = chat[messageId];
53
54 if (!canOpenSwipePickerForMessage(messageId)) {
55 toastr.info(t`This message has no alternate swipes yet.`, t`Jump to Swipe`);
56 return;
57 }
58
59 const canJumpToSwipe = canJumpToSwipeForMessage(messageId);
60 let selectedSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
61 const swipeIdInputId = `swipe_picker_id_${messageId}`;
62 const wrapper = document.createElement('div');
63 wrapper.classList.add('flex-container', 'flexFlowColumn', 'flexNoGap', 'wide100p', 'flex1', 'overflowHidden');
64
65 const header = document.createElement('div');
66 header.classList.add('swipe_picker_header', 'flex-container', 'alignItemsCenter', 'justifySpaceBetween', 'gap10px');
67
68 const description = document.createElement('h3');
69 description.classList.add('margin0', 'justifyLeft');
70 description.textContent = t`Swipe Selection`;
71 header.appendChild(description);
72 wrapper.appendChild(header);
73
74 const listContainer = document.createElement('div');
75 listContainer.classList.add('swipe_picker_div', 'flex1', 'marginTop10');
76 wrapper.appendChild(listContainer);
77
78 /** @type {Popup} */
79 let popup;
80 /** @type {HTMLInputElement} */
81 let swipeIdInput;
82 /** @type {number|null} */
83 let branchActionSwipeId = null;
84
85 function syncSwipeIdInput() {
86 if (swipeIdInput) {
87 swipeIdInput.value = String(selectedSwipeId + 1);
88 }
89 }
90
91 function setSelectedSwipe(nextSwipeId) {
92 selectedSwipeId = clamp(Number(nextSwipeId), 0, message.swipes.length - 1);
93 listContainer.querySelectorAll('.swipe_picker_block').forEach((element) => {
94 const isSelected = Number(element.getAttribute('data-swipe-id')) === selectedSwipeId;
95 if (isSelected) {
96 element.setAttribute('highlight', 'true');
97 } else {
98 element.removeAttribute('highlight');
99 }
100 });
101 syncSwipeIdInput();
102 }
103
104 function scrollToSelectedSwipe() {
105 const swipeBlock = listContainer.querySelector(`.swipe_picker_block[data-swipe-id="${selectedSwipeId}"]`);
106 if (swipeBlock instanceof HTMLElement) {
107 const scrollParent = swipeBlock.closest('.swipe_picker_div');
108 if (scrollParent instanceof HTMLElement) {
109 const blockRect = swipeBlock.getBoundingClientRect();
110 const parentRect = scrollParent.getBoundingClientRect();
111 if (blockRect.top < parentRect.top) {
112 scrollParent.scrollTop -= (parentRect.top - blockRect.top) + 5;
113 } else if (blockRect.bottom > parentRect.bottom) {
114 scrollParent.scrollTop += (blockRect.bottom - parentRect.bottom) + 5;
115 }
116 }
117 }
118 }
119
120 function canDeleteSwipeFromPicker(swipeId) {
121 if ((message?.swipes?.length ?? 0) <= 1) {
122 return false;
123 }
124
125 const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
126 return canJumpToSwipe || swipeId !== currentSwipeId;
127 }
128
129 async function renderSwipeList() {
130 const swipeBlocks = await Promise.all(message.swipes.map(async (swipe, index) => {
131 const swipeText = String(swipe ?? '');
132 const template = $('#past_chat_template .select_chat_block_wrapper').clone();
133 const block = template.find('.select_chat_block');
134 block.removeClass('select_chat_block').addClass('swipe_picker_block');
135 const branchButton = template.find('.exportRawChatButton');
136 const deleteButton = template.find('.PastChat_cross');
137 const swipeInfo = Array.isArray(message.swipe_info) ? message.swipe_info[index] : null;
138 const sendDate = swipeInfo?.send_date ? timestampToMoment(swipeInfo.send_date).format('lll') : '';
139 const previewText = swipeText.replace(/\s+/g, ' ').trim();
140 const tokenCount = swipeInfo?.extra?.token_count ?? await getTokenCountAsync(swipeText, 0);
141 const canDeleteSwipe = canDeleteSwipeFromPicker(index);
142 const swipeDetails = [];
143
144 if (previewText) {
145 swipeDetails.push(`${previewText.length} ${t`chars`}`);
146 }
147
148 if (tokenCount) {
149 swipeDetails.push(`${tokenCount}t`);
150 }
151
152 block.attr({
153 file_name: `swipe-${index + 1}`,
154 'data-swipe-id': index,
155 });
156
157 template.find('.renameChatButton, .exportChatButton').remove();
158 branchButton
159 .removeAttr('data-format')
160 .attr({
161 title: t`Create Branch`,
162 'data-i18n': '[title]Create Branch',
163 })
164 .removeClass('exportRawChatButton fa-solid fa-file-export')
165 .addClass('swipe_picker_branch mes_button fa-regular fa-code-branch')
166 .on('click', async (event) => {
167 event.preventDefault();
168 event.stopPropagation();
169 setSelectedSwipe(index);
170 branchActionSwipeId = index;
171 await popup.completeCancelled();
172 });
173 deleteButton
174 .removeAttr('file_name')
175 .attr('aria-disabled', String(!canDeleteSwipe))
176 .removeClass('fa-skull')
177 .addClass('swipe_picker_delete fa-trash-can')
178 .toggleClass('hoverglow', canDeleteSwipe)
179 .toggleClass('disabled', !canDeleteSwipe)
180 .each(function () {
181 if (canDeleteSwipe) {
182 $(this)
183 .attr({
184 title: t`Delete Swipe`,
185 'data-i18n': '[title]Delete Swipe',
186 });
187 } else {
188 $(this)
189 .removeAttr('title')
190 .removeAttr('data-i18n');
191 }
192 })
193 .off('click')
194 .on('click', async (event) => {
195 event.preventDefault();
196 event.stopPropagation();
197
198 if (!canDeleteSwipe) {
199 return;
200 }
201
202 const nextSelectedSwipeId = index < selectedSwipeId
203 ? selectedSwipeId - 1
204 : index > selectedSwipeId
205 ? selectedSwipeId
206 : Math.min(selectedSwipeId, message.swipes.length - 2);
207
208 if (power_user.confirm_message_delete) {
209 const result = await callGenericPopup(t`Are you sure you want to delete swipe #${index + 1}?`, POPUP_TYPE.CONFIRM, null, {
210 okButton: t`Delete Swipe`,
211 cancelButton: t`Cancel`,
212 });
213
214 if (result !== POPUP_RESULT.AFFIRMATIVE) {
215 return;
216 }
217 }
218
219 const newSwipeId = await deleteSwipe(index, messageId);
220 if (!Number.isInteger(newSwipeId)) {
221 return;
222 }
223
224 selectedSwipeId = clamp(nextSelectedSwipeId, 0, message.swipes.length - 1);
225
226 if (swipeIdInput instanceof HTMLInputElement) {
227 swipeIdInput.max = String(message.swipes.length);
228 }
229
230 await renderSwipeList();
231 });
232 template.find('.select_chat_block_filename').text(`#${index + 1}${index === Number(message.swipe_id ?? 0) ? ` ${t`[Current]`}` : ''}`);
233 template.find('.chat_messages_date').text(sendDate);
234 template.find('.chat_file_size').text(swipeDetails.length ? `(${swipeDetails[0]}${swipeDetails.length > 1 ? ',' : ')'}` : '');
235 template.find('.chat_messages_num').text(swipeDetails.length > 1 ? `${swipeDetails.slice(1).join(', ')})` : '');
236 template.find('.select_chat_block_mes').text(previewText || t`(empty swipe)`);
237
238 block.on('click', () => setSelectedSwipe(index));
239 block.on('dblclick', async () => {
240 if (!canJumpToSwipe) {
241 return;
242 }
243
244 setSelectedSwipe(index);
245 await popup.completeAffirmative();
246 });
247
248 return template[0];
249 }));
250
251 listContainer.replaceChildren(...swipeBlocks);
252 setSelectedSwipe(selectedSwipeId);
253
254 if (swipeBlocks.length === 0) {
255 const empty = document.createElement('div');
256 empty.classList.add('textAlignCenter', 'opacity50p', 'padding10');
257 empty.textContent = t`No swipes available.`;
258 listContainer.replaceChildren(empty);
259 }
260 }
261
262 popup = new Popup(wrapper, POPUP_TYPE.CONFIRM, '', {
263 okButton: canJumpToSwipe ? t`Go` : false,
264 cancelButton: false,
265 customInputs: [{
266 id: swipeIdInputId,
267 label: t`Swipe ID`,
268 type: 'text',
269 defaultState: String(selectedSwipeId + 1),
270 tooltip: `1-${message.swipes.length}`,
271 }],
272 wider: true,
273 allowVerticalScrolling: true,
274 onOpen: function () {
275 scrollToSelectedSwipe();
276 if (swipeIdInput instanceof HTMLInputElement) {
277 swipeIdInput.focus();
278 swipeIdInput.select();
279 }
280 },
281 onClosing: function (popup) {
282 if (popup.result !== POPUP_RESULT.AFFIRMATIVE) {
283 return true;
284 }
285
286 const swipeIdInput = popup.dlg.querySelector(`#${swipeIdInputId}`);
287 const targetSwipeNumber = Number.parseInt(String(swipeIdInput instanceof HTMLInputElement ? swipeIdInput.value : '').trim(), 10);
288
289 if (!Number.isInteger(targetSwipeNumber) || targetSwipeNumber < 1 || targetSwipeNumber > message.swipes.length) {
290 toastr.warning(t`Enter a swipe ID between 1 and ${message.swipes.length}.`, t`Jump to Swipe`);
291 if (swipeIdInput instanceof HTMLInputElement) {
292 swipeIdInput.focus();
293 swipeIdInput.select();
294 }
295 return false;
296 }
297
298 setSelectedSwipe(targetSwipeNumber - 1);
299 return true;
300 },
301 });
302
303 popup.dlg.classList.add('swipe_picker_popup');
304 popup.closeButton.style.display = 'block';
305 popup.closeButton.classList.add('opacity50p', 'hoverglow', 'fontsize120p');
306 popup.closeButton.style.position = 'static';
307 popup.closeButton.style.top = 'auto';
308 popup.closeButton.style.right = 'auto';
309 popup.closeButton.style.width = 'auto';
310 popup.closeButton.style.height = 'auto';
311 popup.closeButton.style.padding = '0';
312 popup.closeButton.style.filter = 'none';
313 header.appendChild(popup.closeButton);
314
315 swipeIdInput = popup.dlg.querySelector(`#${swipeIdInputId}`);
316 const swipeIdLabel = popup.dlg.querySelector(`label[for="${swipeIdInputId}"]`);
317
318 if (swipeIdLabel instanceof HTMLLabelElement) {
319 swipeIdLabel.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'gap10px', 'margin0');
320 popup.buttonControls.insertBefore(swipeIdLabel, canJumpToSwipe ? popup.okButton : popup.buttonControls.firstChild);
321 popup.inputControls.style.display = 'none';
322 }
323
324 if (swipeIdInput instanceof HTMLInputElement) {
325 swipeIdInput.type = 'number';
326 swipeIdInput.min = '1';
327 swipeIdInput.max = String(message.swipes.length);
328 swipeIdInput.step = '1';
329 swipeIdInput.inputMode = 'numeric';
330 swipeIdInput.classList.add('flex1', 'width100px', 'textAlignCenter');
331 swipeIdInput.setAttribute('autofocus', '');
332 syncSwipeIdInput();
333
334 swipeIdInput.addEventListener('input', function () {
335 const nextSwipeId = Number.parseInt(this.value, 10);
336 if (!Number.isInteger(nextSwipeId) || nextSwipeId < 1 || nextSwipeId > message.swipes.length) {
337 return;
338 }
339
340 setSelectedSwipe(nextSwipeId - 1);
341 scrollToSelectedSwipe();
342 });
343
344 swipeIdInput.addEventListener('blur', function () {
345 syncSwipeIdInput();
346 });
347 }
348
349 await renderSwipeList();
350
351 const popupResult = await popup.show();
352
353 if (branchActionSwipeId !== null) {
354 await branchChat(messageId, { swipeId: branchActionSwipeId });
355 return;
356 }
357
358 if (popupResult !== POPUP_RESULT.AFFIRMATIVE) {
359 return;
360 }
361
362 if (!canJumpToSwipe) {
363 return;
364 }
365
366 const targetSwipeId = clamp(selectedSwipeId, 0, message.swipes.length - 1);
367 const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
368
369 if (targetSwipeId === currentSwipeId) {
370 toastr.info(t`Already showing swipe #${targetSwipeId + 1}.`, t`Jump to Swipe`);
371 return;
372 }
373
374 const direction = targetSwipeId > currentSwipeId ? SWIPE_DIRECTION.RIGHT : SWIPE_DIRECTION.LEFT;
375 await swipe(null, direction, { source: SWIPE_SOURCE.SWIPE_PICKER, forceMesId: messageId, forceSwipeId: targetSwipeId });
376}
377
378export function initSwipePicker() {
379 $(document).on('click', '.swipes-counter.swipe-picker-enabled', async function (e) {
380 e.preventDefault();
381 e.stopPropagation();
382
383 const mesId = Number($(this).closest('.mes').attr('mesid'));
384 await openSwipePicker(mesId);
385 });
386 $(document).on('keydown', '.swipes-counter.swipe-picker-enabled', async function (e) {
387 if (e.key !== ' ') {
388 return;
389 }
390
391 e.preventDefault();
392 e.stopPropagation();
393 const mesId = Number($(this).closest('.mes').attr('mesid'));
394 await openSwipePicker(mesId);
395 });
396 $(document).on('click', '.mes_swipe_picker', async function (e) {
397 e.preventDefault();
398 e.stopPropagation();
399
400 const mesId = Number($(this).closest('.mes').attr('mesid'));
401 await openSwipePicker(mesId);
402 });
403}
public/style.css+54 -6
@@ -1278,6 +1278,29 @@ body .panelControlBar {
1278 pointer-events: auto;1278 pointer-events: auto;
1279}1279}
12801280
1281.swipes-counter.swipe-picker-enabled {
1282 cursor: pointer;
1283}
1284
1285.swipes-counter.swipe-picker-enabled:hover,
1286.swipes-counter.swipe-picker-enabled:focus-visible {
1287 opacity: 0.7;
1288}
1289
1290.swipe_picker_header {
1291 position: sticky;
1292 top: 0;
1293 z-index: 1;
1294}
1295
1296.swipe_picker_popup .popup-body,
1297.swipe_picker_popup .popup-content {
1298 display: flex;
1299 flex-direction: column;
1300 min-height: 0;
1301 overflow: hidden;
1302}
1303
12811304
1282body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {1305body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {
1283 /* Avoid expensive DOM queries */1306 /* Avoid expensive DOM queries */
@@ -4725,13 +4748,15 @@ h5 {
4725 cursor: pointer;4748 cursor: pointer;
4726}4749}
47274750
4728#select_chat_div {4751#select_chat_div,
4752.swipe_picker_div {
4729 padding: 0;4753 padding: 0;
4730 height: 100%;4754 height: 100%;
4731 overflow-y: auto;4755 overflow-y: auto;
4732}4756}
47334757
4734#select_chat_div hr {4758#select_chat_div hr,
4759.swipe_picker_div hr {
4735 margin: 0;4760 margin: 0;
4736}4761}
47374762
@@ -4739,21 +4764,30 @@ h5 {
4739 cursor: pointer;4764 cursor: pointer;
4740}4765}
47414766
4742.select_chat_block {4767.select_chat_block,
4768.swipe_picker_block {
4743 border-radius: 5px;4769 border-radius: 5px;
4744 margin-top: 5px;4770 margin-top: 5px;
4745 border: 1px solid var(--SmartThemeBorderColor);4771 border: 1px solid var(--SmartThemeBorderColor);
4746 padding: 5px 7px;4772 padding: 5px 7px;
4747}4773}
47484774
4749.select_chat_block:hover {4775.select_chat_block:hover,
4776.swipe_picker_block:hover {
4750 background-color: var(--white30a);4777 background-color: var(--white30a);
4751}4778}
47524779
4753.select_chat_block[highlight] {4780.select_chat_block[highlight],
4781.swipe_picker_block[highlight] {
4754 background-color: var(--cobalt30a);4782 background-color: var(--cobalt30a);
4755}4783}
47564784
4785.swipe_picker_block {
4786 cursor: pointer;
4787 -webkit-user-select: none;
4788 user-select: none;
4789}
4790
4757.select_chat_block .avatar {4791.select_chat_block .avatar {
4758 grid-row: span 2;4792 grid-row: span 2;
4759}4793}
@@ -4790,13 +4824,27 @@ h5 {
4790}4824}
47914825
47924826
4793.PastChat_cross:hover {4827.PastChat_cross:not(.disabled):hover {
4794 color: red;4828 color: red;
4795 filter: drop-shadow(0 0 2px red);4829 filter: drop-shadow(0 0 2px red);
4796 -webkit-animation: infinite-spinning 1s ease-out 0s infinite normal;4830 -webkit-animation: infinite-spinning 1s ease-out 0s infinite normal;
4797 animation: infinite-spinning 1s ease-out 0s infinite normal;4831 animation: infinite-spinning 1s ease-out 0s infinite normal;
4798}4832}
47994833
4834.swipe_picker_delete.disabled {
4835 cursor: default !important;
4836 opacity: 0.2;
4837}
4838
4839.swipe_picker_delete.disabled:hover {
4840 cursor: default !important;
4841 color: inherit;
4842 filter: none;
4843 -webkit-animation: none;
4844 animation: none;
4845 opacity: 0.2 !important;
4846}
4847
4800#export_character_div {4848#export_character_div {
4801 display: grid;4849 display: grid;
4802 grid-template-columns: 340px auto;4850 grid-template-columns: 340px auto;