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, +580 -30Showing whitespace changes
public/index.html+1 -0
@@ -7365,6 +7365,7 @@
73657365 <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>
73667366 <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>
73677367 <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>
73687369 <div title="Create checkpoint" class="mes_button mes_create_bookmark fa-regular fa-solid fa-flag-checkered" data-i18n="[title]Create checkpoint"></div>
73697370 <div title="Create branch" class="mes_button mes_create_branch fa-regular fa-code-branch" data-i18n="[title]Create Branch"></div>
73707371 <div title="Copy" class="mes_button mes_copy fa-solid fa-copy " data-i18n="[title]Copy"></div>
public/script.js+67 -17
@@ -286,6 +286,7 @@ import { MacroEngine } from './scripts/macros/engine/MacroEngine.js';
286286import { addChatBackupsBrowser } from './scripts/chat-backups.js';
287287import { onboardingExperimentalMacroEngine } from './scripts/macros/engine/MacroDiagnostics.js';
288288import { compressRequest, setRequestCompressionConfig } from './scripts/request-compression.js';
289+import { canJumpToSwipeForMessage, canOpenSwipePickerForMessage, initSwipePicker } from './scripts/swipe-picker.js';
289290
290291// API OBJECT FOR EXTERNAL WIRING
291292globalThis.SillyTavern = {
@@ -776,6 +777,7 @@ async function firstLoadInit() {
776777 initDataMaid();
777778 initItemizedPrompts();
778779 initAccessibility();
780+ initSwipePicker();
779781 addDebugFunctions();
780782 doDailyExtensionUpdatesCheck();
781783 await eventSource.emit(event_types.APP_INITIALIZED);
@@ -6847,20 +6849,24 @@ export function syncMesToSwipe(messageId = null) {
68476849 * If the swipe data is invalid in some way, this function will exit out without doing anything.
68486850 * @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.
68496851 * @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`.
68506853 * @returns {boolean} Whether the swipe data was successfully synced to the message
68516854 */
68526855export function syncSwipeToMes(messageId = null, swipeId = null, targetMessage = null) {
68536856 if (!targetMessage && !chat.length) {
68546857 return false;
68556858 }
68566859
6860+ if (!targetMessage) {
68576861 const targetMessageId = messageId ?? chat.length - 1;
68586862 if (targetMessageId >= chat.length || targetMessageId < 0) {
68596863 console.warn(`[syncSwipeToMes] Invalid message ID: ${messageId}`);
68606864 return false;
68616865 }
68626866
68636867 const targetMessage = chat[targetMessageId];
6868+ }
6869+
68646870 if (!targetMessage) {
68656871 return false;
68666872 }
@@ -7283,10 +7289,11 @@ export function saveChatDebounced() {
72837289 * @param {object} [options.withMetadata] Additional metadata to save with the chat
72847290 * @param {number} [options.mesId] The message ID to save the chat up to
72857291 * @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
72867293 *
72877294 * @returns {Promise<void>}
72887295 */
72897296export async function saveChat({ chatName, withMetadata, mesId, force = false, chatData = undefined } = {}) {
72907297 if (selected_group) {
72917298 toastr.error(t`Operation was aborted to prevent data corruption.`, t`saveChat called for a group chat`);
72927299 throw new Error('saveChat called for a group chat');
@@ -7312,7 +7319,9 @@ export async function saveChat({ chatName, withMetadata, mesId, force = false }
73127319
73137320 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)
7323+ ? chatData
7324+ : (mesId !== undefined && mesId >= 0 && mesId < chat.length)
73167325 ? chat.slice(0, Number(mesId) + 1)
73177326 : chat.slice();
73187327
@@ -9019,7 +9028,22 @@ export async function updateSwipeCounter(mesId, { message = undefined, messageEl
90199028
90209029 const swipeCounterText = formatSwipeCounter((message?.swipe_id + 1), message?.swipes?.length);
90219030 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+ }
90239047}
90249048
90259049/**
@@ -9148,6 +9172,8 @@ export function refreshSwipeButtons(updateCounters = false, fade = true) {
91489172 const isLastSwipe = (message?.swipes?.length ?? 1) - 1 <= (message?.swipe_id ?? 0);
91499173 const hasSwipes = (message?.swipes?.length > 1);
91509174 const overswipe = getOverswipeBehavior(messageId, message);
9175+ const swipePickerButton = $(div).find('.mes_swipe_picker');
9176+ const canOpenSwipePicker = canOpenSwipePickerForMessage(messageId);
91519177
91529178 // Chevrons should always be shown on pristine greetings: https://github.com/SillyTavern/SillyTavern/pull/4712#issuecomment-3557893373
91539179 const pristineGreeting = overswipe == OVERSWIPE_BEHAVIOR.PRISTINE_GREETING;
@@ -9161,12 +9187,14 @@ export function refreshSwipeButtons(updateCounters = false, fade = true) {
91619187
91629188 //If there's only one swipe, the left arrow should not be shown.
91639189 div.classList.toggle('swipes_visible', hasSwipes || pristineGreeting);
9190+ swipePickerButton.toggle(canOpenSwipePicker);
91649191
91659192 //updateSwipeCounter does not need to be awaited, It can run a bit later.
91669193 if (updateCounters) updateSwipeCounter(messageId, { message, messageElement: $(div) });
91679194 } else {
91689195 //Hide all messages that are not swipeable.
91699196 div.classList.remove('swipes_visible', 'last_swipe');
9197+ $(div).find('.mes_swipe_picker').toggle(canOpenSwipePickerForMessage(messageId));
91709198 }
91719199 });
91729200}
@@ -9200,10 +9228,13 @@ export function hideSwipeButtons({ hideCounters = false } = {}) {
92009228 * @returns {Promise<number>|undefined} - The ID of the new swipe after deletion.
92019229 */
92029230export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
92039231 if (swipeId && (isNaN(swipeId) || swipeId <!= 0)null) {
9204- toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
9232+ swipeId = Number(swipeId);
9233+ if (!Number.isInteger(swipeId) || swipeId < 0) {
9234+ toastr.warning(t`Invalid swipe ID.`);
92059235 return;
92069236 }
9237+ }
92079238
92089239 const message = chat[messageId];
92099240 if (!message || !Array.isArray(message.swipes) || !message.swipes.length) {
@@ -9216,7 +9247,8 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
92169247 return;
92179248 }
92189249
92199250 swipeId = Number(swipeId ?? message.swipe_id);
9251+ const currentSwipeId = clamp(Number(message.swipe_id ?? 0), 0, message.swipes.length - 1);
92209252
92219253 if (swipeId < 0 || swipeId >= message.swipes.length) {
92229254 toastr.warning(t`Invalid swipe ID: ${swipeId + 1}`);
@@ -9229,17 +9261,35 @@ export async function deleteSwipe(swipeId = null, messageId = chat.length - 1) {
92299261 message.swipe_info.splice(swipeId, 1);
92309262 }
92319263
9232- // Select the next swipe, or the one before if it was the last one
9264+ 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
92359274 chat_metadata.tainted = true;
92369275
92379276 messageId = Number(messageId);
92389277 swipeId = Number(swipeId);
9278+ message.swipe_id = newSwipeId;
92399279 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) {
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.
92429284 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
92449294 await saveChatConditional();
92459295
@@ -9785,7 +9835,7 @@ function formatSwipeCounter(current, total) {
97859835 * @param {SwipeEvent} event Event.
97869836 * @param {SWIPE_DIRECTION} direction The direction to swipe.
97879837 * @param {object} params Additional parameters.
97889838 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
97899839 * @param {boolean} [params.repeated] Is the swipe event repeated.
97909840 * @param {ChatMessage} [params.message=chat[chat.length - 1]] The chat message to swipe.
97919841 * @param {number} [params.forceMesId] The message id to swipe.
@@ -9811,7 +9861,7 @@ export async function swipe(event, direction, { source, repeated, message = chat
98119861
98129862 const mesId = Number(forceMesId ?? event?.currentTarget?.closest('.mes')?.getAttribute('mesid') ?? messageIndex ?? chat.length - 1);
98139863
98149864 if ([SWIPE_SOURCE.DELETE, SWIPE_SOURCE.BACK, SWIPE_SOURCE.AUTO_SWIPE, SWIPE_SOURCE.SLASH_COMMAND, SWIPE_SOURCE.SWIPE_PICKER].includes(source)) {
98159865 console.info(`The ${direction} swipe source on message #${mesId} is ${source}, Most checks have been bypassed. `);
98169866 } else {
98179867 //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
1027110321 * Handles the swipe to the left event.
1027210322 * @param {SwipeEvent} [event] Event.
1027310323 * @param {object} params Additional parameters.
1027410324 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
1027510325 * @param {boolean} [params.repeated] Is the swipe event repeated.
1027610326 * @param {object} [params.message] The chat message to swipe.
1027710327 */
@@ -10284,7 +10334,7 @@ export async function swipe_left(event, { source, repeated, message } = {}) {
1028410334 * Handles the swipe to the right event.
1028510335 * @param {SwipeEvent} [event] Event.
1028610336 * @param {object} params Additional parameters.
1028710337 * @param {import('./scripts/constants.js').SWIPE_SOURCE} [params.source] The source of the swipe event. null, 'keyboard', 'auto_swipe', 'back' or 'delete'.
1028810338 * @param {boolean} [params.repeated] Is the swipe event repeated.
1028910339 * @param {object} [params.message] The chat message to swipe.
1029010340 */
public/scripts/bookmarks.js+43 -5
@@ -2,6 +2,7 @@ import {
22 characters,
33 saveChat,
44 system_message_types,
5+ syncSwipeToMes,
56 this_chid,
67 openCharacterChat,
78 chat_metadata,
@@ -161,8 +162,28 @@ async function saveBookmarkMenu() {
161162 return await createNewBookmark(chat.length - 1);
162163}
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+ */
171+function 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+
164185// Export is used by Timelines extension. Do not remove.
165186export async function createBranch(mesId, { swipeId = null } = {}) {
166187 if (!chat.length) {
167188 toastr.warning('The chat is empty.', 'Branch creation failed');
168189 return;
@@ -176,6 +197,12 @@ export async function createBranch(mesId) {
176197 const lastMes = chat[mesId];
177198 const mainChatName = (getCurrentChatDetails()).sessionName;
178199 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
180207 function buildBranchName(name, i) {
181208 // Strip off existing suffixes, then build new name
@@ -192,10 +219,16 @@ export async function createBranch(mesId) {
192219 return;
193220 }
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+
195228 if (selected_group) {
196229 await saveGroupBookmarkChat(selected_group, name, newMetadata, mesId, branchChatSnapshot);
197230 } else {
198231 await saveChat({ chatName: name, withMetadata: newMetadata, mesId, chatData: branchChatSnapshot });
199232 }
200233 // append to branches list if it exists
201234 // otherwise create it
@@ -410,15 +443,20 @@ export async function convertSoloToGroupChat() {
410443/**
411444 * Creates a new branch from the message with the given ID
412445 * @param {number} mesId Message ID
446+ * @param {{swipeId?: number|null}} [options={}] Branch options
413447 * @returns {Promise<string?>} Branch file name
414448 */
415449export async function branchChat(mesId, { swipeId = null } = {}) {
416450 if (this_chid === undefined && !selected_group) {
417451 toastr.info('No character selected.', 'Create Branch');
418452 return null;
419453 }
420454
421455 const fileName = await createBranch(mesId, { swipeId });
456+ if (!fileName) {
457+ return null;
458+ }
459+
422460 await saveItemizedPrompts(fileName);
423461
424462 if (selected_group) {
public/scripts/constants.js+1 -0
@@ -176,6 +176,7 @@ export const SWIPE_SOURCE = {
176176 BACK: 'back',
177177 AUTO_SWIPE: 'auto_swipe',
178178 SLASH_COMMAND: 'slash_command',
179+ SWIPE_PICKER: 'swipe_picker',
179180};
180181
181182/**
public/scripts/group-chats.js+5 -2
@@ -2352,9 +2352,10 @@ export async function importGroupChat(formData, { refresh = true } = {}) {
23522352 * @param {string} name Name of the chat to save
23532353 * @param {ChatMetadata?} metadata New metadata to save with the chat
23542354 * @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
23552356 * @returns {Promise<void>} Promise that resolves when the group chat is saved
23562357 */
23572358export async function saveGroupBookmarkChat(groupId, name, metadata, mesId, chatData = undefined) {
23582359 const group = groups.find(x => x.id === groupId);
23592360
23602361 if (!group) {
@@ -2371,7 +2372,9 @@ export async function saveGroupBookmarkChat(groupId, name, metadata, mesId) {
23712372 };
23722373
23732374 /** @type {ChatMessage[]} */
2374- const trimmedChat = (mesId !== undefined && mesId >= 0 && mesId < chat.length)
2375+ const trimmedChat = Array.isArray(chatData)
2376+ ? chatData
2377+ : (mesId !== undefined && mesId >= 0 && mesId < chat.length)
23752378 ? chat.slice(0, Number(mesId) + 1)
23762379 : chat;
23772380
public/scripts/keyboard.js+6 -0
@@ -8,6 +8,7 @@ const interactableSelectors = [
88 '.inline-drawer-icon', // Buttons/icons inside the drawer menus
99 '.paginationjs-pages li a', // Pagination buttons
1010 '.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
1112 '.avatar-container', // Persona list blocks
1213 '.tag .tag_remove', // Remove button in removable tags
1314 '.bg_example', // Background elements in the background menu
@@ -20,6 +21,11 @@ const interactableSelectors = [
2021 '.select2_choice_clickable+span.select2-container .select2-selection__choice__display', // select2 control elements if they are meant to be clickable
2122 '.avatar_load_preview', // Char display avatar selection
2223 '.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
2329];
2430
2531if (CSS.supports('selector(:has(*))')) {
public/scripts/swipe-picker.js+403 -0
@@ -0,0 +1,403 @@
1+import { branchChat } from './bookmarks.js';
2+import { SWIPE_DIRECTION, SWIPE_SOURCE } from './constants.js';
3+import { t } from './i18n.js';
4+import { callGenericPopup, Popup, POPUP_RESULT, POPUP_TYPE } from './popup.js';
5+import { power_user } from './power-user.js';
6+import { getTokenCountAsync } from './tokenizers.js';
7+import { clamp, timestampToMoment } from './utils.js';
8+import { 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+ */
16+export 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+ */
41+export 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+ */
51+async 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+
378+export 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 {
12781278 pointer-events: auto;
12791279}
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
12821305body.swipeAllMessages .mes:not(.last_mes) .swipes-counter {
12831306 /* Avoid expensive DOM queries */
@@ -4725,13 +4748,15 @@ h5 {
47254748 cursor: pointer;
47264749}
47274750
47284751#select_chat_div {,
4752+.swipe_picker_div {
47294753 padding: 0;
47304754 height: 100%;
47314755 overflow-y: auto;
47324756}
47334757
47344758#select_chat_div hr {,
4759+.swipe_picker_div hr {
47354760 margin: 0;
47364761}
47374762
@@ -4739,21 +4764,30 @@ h5 {
47394764 cursor: pointer;
47404765}
47414766
47424767.select_chat_block {,
4768+.swipe_picker_block {
47434769 border-radius: 5px;
47444770 margin-top: 5px;
47454771 border: 1px solid var(--SmartThemeBorderColor);
47464772 padding: 5px 7px;
47474773}
47484774
47494775.select_chat_block:hover {,
4776+.swipe_picker_block:hover {
47504777 background-color: var(--white30a);
47514778}
47524779
47534780.select_chat_block[highlight] {,
4781+.swipe_picker_block[highlight] {
47544782 background-color: var(--cobalt30a);
47554783}
47564784
4785+.swipe_picker_block {
4786+ cursor: pointer;
4787+ -webkit-user-select: none;
4788+ user-select: none;
4789+}
4790+
47574791.select_chat_block .avatar {
47584792 grid-row: span 2;
47594793}
@@ -4790,13 +4824,27 @@ h5 {
47904824}
47914825
47924826
47934827.PastChat_cross:not(.disabled):hover {
47944828 color: red;
47954829 filter: drop-shadow(0 0 2px red);
47964830 -webkit-animation: infinite-spinning 1s ease-out 0s infinite normal;
47974831 animation: infinite-spinning 1s ease-out 0s infinite normal;
47984832}
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+
48004848#export_character_div {
48014849 display: grid;
48024850 grid-template-columns: 340px auto;