diff --git a/application/single_app/functions_conversation_contents.py b/application/single_app/functions_conversation_contents.py new file mode 100644 index 000000000..4cec31d3f --- /dev/null +++ b/application/single_app/functions_conversation_contents.py @@ -0,0 +1,11 @@ +# functions_conversation_contents.py + + +def is_conversation_contents_drawer_enabled(app_settings, user_settings): + """Return whether both the admin gate and current-user preference allow the drawer.""" + normalized_app_settings = app_settings if isinstance(app_settings, dict) else {} + normalized_user_settings = user_settings if isinstance(user_settings, dict) else {} + return bool( + normalized_app_settings.get('enable_conversation_contents_drawer', True) + and normalized_user_settings.get('conversationContentsDrawerEnabled', True) + ) diff --git a/application/single_app/functions_settings.py b/application/single_app/functions_settings.py index 709a6f908..f242de625 100644 --- a/application/single_app/functions_settings.py +++ b/application/single_app/functions_settings.py @@ -981,6 +981,7 @@ def get_settings(use_cosmos=False, include_source=False): 'require_public_workspace_assignment_for_file_downloads': False, 'file_download_allowed_public_workspace_ids': [], 'enable_chat_file_uploads': True, + 'enable_conversation_contents_drawer': True, 'require_member_of_chat_file_upload_user': False, 'enforce_workspace_scope_lock': True, diff --git a/application/single_app/route_backend_users.py b/application/single_app/route_backend_users.py index f33d0accc..20d5cf1f9 100644 --- a/application/single_app/route_backend_users.py +++ b/application/single_app/route_backend_users.py @@ -507,6 +507,7 @@ def user_settings(): 'navbar_layout', 'chatLayout', 'showChatTitle', 'chatSplitSizes', 'deepResearchDefaultEnabled', 'sidebarToggleStyle', 'sidebarMenuState', 'fontSizePreference', + 'conversationContentsDrawerEnabled', LATEST_FEATURES_HIDDEN_VERSION_SETTING, # Microphone permission settings 'microphonePermissionPreference', 'microphonePermissionState', @@ -550,6 +551,10 @@ def user_settings(): return jsonify({"error": "Invalid font size preference"}), 400 settings_to_update["fontSizePreference"] = font_size_preference + if "conversationContentsDrawerEnabled" in settings_to_update: + if not isinstance(settings_to_update["conversationContentsDrawerEnabled"], bool): + return jsonify({"error": "Invalid conversation contents drawer preference"}), 400 + if "sidebarMenuState" in settings_to_update: sidebar_menu_state = settings_to_update.get("sidebarMenuState") allowed_sidebar_menu_keys = { diff --git a/application/single_app/route_frontend_admin_settings.py b/application/single_app/route_frontend_admin_settings.py index 366a34c32..c4f26f62b 100644 --- a/application/single_app/route_frontend_admin_settings.py +++ b/application/single_app/route_frontend_admin_settings.py @@ -185,6 +185,8 @@ def admin_settings(): settings['require_member_of_create_public_workspace'] = False if 'enable_chat_file_uploads' not in settings: settings['enable_chat_file_uploads'] = True + if 'enable_conversation_contents_drawer' not in settings: + settings['enable_conversation_contents_drawer'] = True if 'require_member_of_chat_file_upload_user' not in settings: settings['require_member_of_chat_file_upload_user'] = False if 'require_member_of_safety_violation_admin' not in settings: @@ -1989,6 +1991,7 @@ def is_valid_url(url): 'enable_public_workspaces': form_data.get('enable_public_workspaces') == 'on', 'enable_file_sharing': form_data.get('enable_file_sharing') == 'on', 'enable_chat_file_uploads': form_data.get('enable_chat_file_uploads') == 'on', + 'enable_conversation_contents_drawer': form_data.get('enable_conversation_contents_drawer') == 'on', 'require_member_of_chat_file_upload_user': require_member_of_chat_file_upload_user, 'allow_user_workflows': form_data.get('allow_user_workflows') == 'on', 'require_member_of_workflow_user': require_member_of_workflow_user, diff --git a/application/single_app/route_frontend_chats.py b/application/single_app/route_frontend_chats.py index 5af7d93a6..8f81fcc23 100644 --- a/application/single_app/route_frontend_chats.py +++ b/application/single_app/route_frontend_chats.py @@ -18,6 +18,7 @@ serialize_collaboration_conversation, serialize_collaboration_message, ) +from functions_conversation_contents import is_conversation_contents_drawer_enabled from functions_source_review import get_deep_research_config, is_source_review_enabled_for_user, is_url_access_enabled_for_user from functions_documents import * from functions_group import ( @@ -700,6 +701,10 @@ def chats(): public_settings['enable_url_access'] = url_access_enabled_for_user public_settings['enable_chat_file_uploads'] = chat_file_upload_enabled_for_user public_settings['allow_user_workflows'] = user_workflows_enabled_for_user + conversation_contents_drawer_enabled = is_conversation_contents_drawer_enabled( + public_settings, + user_settings_dict, + ) public_settings['enable_deep_source_review'] = bool( source_review_enabled_for_user and settings.get('enable_deep_source_review', False) ) @@ -868,6 +873,7 @@ def chats(): chat_agent_options=chat_agent_options, chat_model_options=chat_model_options, initial_chat_model_selection=initial_chat_model_selection, + conversation_contents_drawer_enabled=conversation_contents_drawer_enabled, ) @bp.route('/workflow-activity', methods=['GET']) diff --git a/application/single_app/static/css/chats.css b/application/single_app/static/css/chats.css index 1d0b80ef9..b40a02717 100644 --- a/application/single_app/static/css/chats.css +++ b/application/single_app/static/css/chats.css @@ -1685,6 +1685,93 @@ body.layout-split .gutter { flex-direction: column; height: 100%; min-height: 0; + position: relative; +} + +.conversation-contents-drawer { + --bs-offcanvas-width: min(22rem, 88vw); +} + +.conversation-contents-list { + display: grid; + gap: 0.25rem; +} + +.conversation-contents-entry { + border-radius: 0.375rem; + color: var(--bs-body-color); + display: block; + overflow: hidden; + padding: 0.625rem 0.75rem; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + width: 100%; +} + +.conversation-contents-entry:hover, +.conversation-contents-entry:focus-visible { + background: var(--bs-tertiary-bg); +} + +.conversation-contents-entry[aria-current="location"] { + background: rgba(var(--bs-primary-rgb), 0.12); + color: var(--bs-primary); + font-weight: 600; +} + +.conversation-contents-destination { + animation: conversation-contents-highlight 1.6s ease-out; + border-radius: 0.5rem; +} + +@keyframes conversation-contents-highlight { + 0%, + 35% { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.3); + } + 100% { + box-shadow: 0 0 0 0 rgba(var(--bs-primary-rgb), 0); + } +} + +@media (min-width: 1200px) { + .chat-container { + transition: padding-right 180ms ease; + } + + .chat-container.conversation-contents-open { + padding-right: 19rem; + } + + .conversation-contents-drawer { + --bs-offcanvas-width: 19rem; + bottom: 0; + height: 100%; + position: absolute; + top: 0; + transform: translateX(100%); + transition: transform 180ms ease, visibility 180ms ease; + visibility: hidden; + z-index: 5; + } + + .chat-container.conversation-contents-open .conversation-contents-drawer { + transform: none; + visibility: visible; + } +} + +@media (prefers-reduced-motion: reduce) { + .chat-container, + .conversation-contents-drawer { + transition: none; + } + + .conversation-contents-destination { + animation: none; + box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.3); + } } html[data-font-size="l"] .chat-shell-header, diff --git a/application/single_app/static/js/chat/chat-conversation-contents.js b/application/single_app/static/js/chat/chat-conversation-contents.js new file mode 100644 index 000000000..1bb7e9623 --- /dev/null +++ b/application/single_app/static/js/chat/chat-conversation-contents.js @@ -0,0 +1,353 @@ +// chat-conversation-contents.js + +const CONTENTS_LABEL_MAX_LENGTH = 72; +const DESKTOP_MEDIA_QUERY = "(min-width: 1200px)"; +const TEMP_MESSAGE_PREFIX = "temp_user_"; + +const chatContainer = document.querySelector(".chat-container"); +const chatbox = document.getElementById("chatbox"); +const scrollContainer = document.getElementById("chat-messages-container"); +const drawer = document.getElementById("conversation-contents-drawer"); +const list = document.getElementById("conversation-contents-list"); +const emptyState = document.getElementById("conversation-contents-empty"); +const toggleButton = document.getElementById("conversation-contents-toggle"); +const closeButton = document.getElementById("conversation-contents-close"); +const desktopMediaQuery = window.matchMedia(DESKTOP_MEDIA_QUERY); + +let contentsEntries = []; +let activeMessageId = ""; +let drawerOpen = false; +let rebuildScheduled = false; +let scrollUpdateScheduled = false; +let offcanvasInstance = null; +let highlightTimeout = null; +let restoreFocusAfterClose = true; + +export function normalizeConversationContentsLabel(value, fallbackLabel = "User message") { + const source = String(value || "").replace(/\r\n?/g, "\n"); + const firstMeaningfulLine = source + .split("\n") + .map(line => line + .replace(/<[^>]*>/g, " ") + .replace(/!\[[^\]]*]\([^)]*\)/g, " ") + .replace(/\[([^\]]+)]\([^)]*\)/g, "$1") + .replace(/^\s*\d+[.)]\s+/, "") + .replace(/^[\s>*#`~\-+_]+/, "") + .replace(/[*_`~]/g, "") + .replace(/\s+/g, " ") + .trim()) + .find(Boolean); + const normalizedLabel = firstMeaningfulLine || fallbackLabel; + + if (normalizedLabel.length <= CONTENTS_LABEL_MAX_LENGTH) { + return normalizedLabel; + } + + return `${normalizedLabel.slice(0, CONTENTS_LABEL_MAX_LENGTH - 1).trimEnd()}…`; +} + +function isPersistedUserMessage(messageElement) { + const messageId = String(messageElement.dataset.messageId || "").trim(); + return ( + messageElement.dataset.conversationContentsRole === "user" + && Boolean(messageId) + && !messageId.startsWith(TEMP_MESSAGE_PREFIX) + ); +} + +function getMessageLabel(messageElement, index) { + const sourceText = messageElement.conversationContentsText; + const messageText = messageElement.querySelector(".message-text"); + const firstTextBlock = messageText?.querySelector( + "p, h1, h2, h3, h4, h5, h6, li, pre, blockquote" + ); + const renderedText = firstTextBlock?.textContent || messageText?.textContent || ""; + return normalizeConversationContentsLabel( + renderedText || sourceText, + `User message ${index + 1}` + ); +} + +function setActiveEntry(messageId) { + if (activeMessageId === messageId) { + return; + } + + activeMessageId = messageId; + contentsEntries.forEach(entry => { + if (entry.messageId === messageId) { + entry.button.setAttribute("aria-current", "location"); + } else { + entry.button.removeAttribute("aria-current"); + } + }); +} + +function findNearestEntry() { + if (!scrollContainer || contentsEntries.length === 0) { + return null; + } + + const targetOffset = scrollContainer.scrollTop + 24; + let low = 0; + let high = contentsEntries.length - 1; + + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (contentsEntries[middle].message.offsetTop <= targetOffset) { + low = middle; + } else { + high = middle - 1; + } + } + + return contentsEntries[low]; +} + +function updateActiveEntry() { + scrollUpdateScheduled = false; + const nearestEntry = findNearestEntry(); + setActiveEntry(nearestEntry?.messageId || ""); +} + +function scheduleActiveEntryUpdate() { + if (scrollUpdateScheduled) { + return; + } + + scrollUpdateScheduled = true; + window.requestAnimationFrame(updateActiveEntry); +} + +function closeDrawer({ restoreFocus = true } = {}) { + if (!drawerOpen) { + return; + } + + if (desktopMediaQuery.matches) { + drawerOpen = false; + chatContainer?.classList.remove("conversation-contents-open"); + drawer?.setAttribute("aria-hidden", "true"); + toggleButton?.setAttribute("aria-expanded", "false"); + if (restoreFocus) { + toggleButton?.focus(); + } + return; + } + + restoreFocusAfterClose = restoreFocus; + offcanvasInstance?.hide(); +} + +function openDrawer() { + if (!drawer || contentsEntries.length === 0) { + return; + } + + drawerOpen = true; + toggleButton?.setAttribute("aria-expanded", "true"); + + if (desktopMediaQuery.matches) { + chatContainer?.classList.add("conversation-contents-open"); + drawer.setAttribute("aria-hidden", "false"); + list?.querySelector("button")?.focus(); + return; + } + + offcanvasInstance?.show(); +} + +function toggleDrawer() { + if (drawerOpen) { + closeDrawer(); + } else { + openDrawer(); + } +} + +function navigateToMessage(entry) { + const messageElement = entry.message; + messageElement.scrollIntoView({ behavior: "smooth", block: "start" }); + messageElement.tabIndex = -1; + messageElement.focus({ preventScroll: true }); + messageElement.classList.remove("conversation-contents-destination"); + window.requestAnimationFrame(() => { + messageElement.classList.add("conversation-contents-destination"); + }); + + window.clearTimeout(highlightTimeout); + highlightTimeout = window.setTimeout(() => { + messageElement.classList.remove("conversation-contents-destination"); + }, 1800); + setActiveEntry(entry.messageId); + + if (!desktopMediaQuery.matches) { + closeDrawer({ restoreFocus: false }); + } +} + +function createEntry(messageElement, index) { + const messageId = String(messageElement.dataset.messageId || ""); + const listItem = document.createElement("li"); + const button = document.createElement("button"); + + button.type = "button"; + button.className = "btn btn-link text-decoration-none conversation-contents-entry"; + button.textContent = getMessageLabel(messageElement, index); + button.title = button.textContent; + button.dataset.messageId = messageId; + button.addEventListener("click", () => navigateToMessage({ + button, + message: messageElement, + messageId + })); + listItem.appendChild(button); + + return { + button, + listItem, + message: messageElement, + messageId + }; +} + +export function rebuildConversationContents() { + rebuildScheduled = false; + if (!chatbox || !list || !emptyState || !toggleButton) { + return; + } + + const messages = Array.from( + chatbox.querySelectorAll('.message[data-conversation-contents-role="user"]') + ).filter(isPersistedUserMessage); + + const previousActiveMessageId = activeMessageId; + const focusedMessageId = list.contains(document.activeElement) + ? String(document.activeElement.dataset.messageId || "") + : ""; + list.replaceChildren(); + contentsEntries = messages.map(createEntry); + contentsEntries.forEach(entry => list.appendChild(entry.listItem)); + activeMessageId = ""; + if (contentsEntries.some(entry => entry.messageId === previousActiveMessageId)) { + setActiveEntry(previousActiveMessageId); + } + if (focusedMessageId) { + contentsEntries + .find(entry => entry.messageId === focusedMessageId) + ?.button.focus({ preventScroll: true }); + } + + const hasEntries = contentsEntries.length > 0; + toggleButton.classList.toggle("d-none", !hasEntries); + toggleButton.disabled = !hasEntries; + emptyState.classList.toggle("d-none", hasEntries); + + if (!hasEntries) { + closeDrawer({ restoreFocus: false }); + setActiveEntry(""); + return; + } + + scheduleActiveEntryUpdate(); +} + +function scheduleRebuild() { + if (rebuildScheduled) { + return; + } + + rebuildScheduled = true; + window.requestAnimationFrame(rebuildConversationContents); +} + +function handleViewportChange() { + if (drawer?.classList.contains("show")) { + offcanvasInstance?.hide(); + } + chatContainer?.classList.remove("conversation-contents-open"); + drawerOpen = false; + drawer?.setAttribute("aria-hidden", "true"); + toggleButton?.setAttribute("aria-expanded", "false"); +} + +function mutationAffectsContents(mutations) { + return mutations.some(mutation => { + if (mutation.type === "attributes") { + return mutation.target?.dataset?.conversationContentsRole === "user"; + } + + const targetElement = mutation.target.nodeType === Node.ELEMENT_NODE + ? mutation.target + : mutation.target.parentElement; + if (targetElement?.closest?.('.message[data-conversation-contents-role="user"]')) { + return true; + } + + return [...mutation.addedNodes, ...mutation.removedNodes].some(node => ( + node.nodeType === Node.ELEMENT_NODE + && ( + node.matches?.('.message[data-conversation-contents-role="user"]') + || node.querySelector?.('.message[data-conversation-contents-role="user"]') + ) + )); + }); +} + +function handleMessageMutations(mutations) { + if (mutationAffectsContents(mutations)) { + scheduleRebuild(); + } +} + +function initializeConversationContents() { + if (!chatbox || !drawer || !list || !toggleButton || !closeButton) { + return; + } + + if (typeof bootstrap !== "undefined") { + offcanvasInstance = bootstrap.Offcanvas.getOrCreateInstance(drawer, { + backdrop: true, + keyboard: true, + scroll: false + }); + drawer.addEventListener("shown.bs.offcanvas", () => { + drawerOpen = true; + drawer.setAttribute("aria-hidden", "false"); + toggleButton.setAttribute("aria-expanded", "true"); + }); + drawer.addEventListener("hidden.bs.offcanvas", () => { + drawerOpen = false; + drawer.setAttribute("aria-hidden", "true"); + toggleButton.setAttribute("aria-expanded", "false"); + if (restoreFocusAfterClose) { + toggleButton.focus(); + } + restoreFocusAfterClose = true; + }); + } + + toggleButton.addEventListener("click", toggleDrawer); + closeButton.addEventListener("click", () => closeDrawer()); + scrollContainer?.addEventListener("scroll", scheduleActiveEntryUpdate, { passive: true }); + desktopMediaQuery.addEventListener("change", handleViewportChange); + document.addEventListener("keydown", event => { + if (event.key === "Escape" && drawerOpen && desktopMediaQuery.matches) { + event.preventDefault(); + closeDrawer(); + } + }); + + const messageObserver = new MutationObserver(handleMessageMutations); + messageObserver.observe(chatbox, { + attributes: true, + attributeFilter: ["data-message-id"], + characterData: true, + childList: true, + subtree: true + }); + + rebuildConversationContents(); +} + +initializeConversationContents(); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index 7564cf24b..a0ecd2e8f 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -4816,6 +4816,10 @@ export function appendMessage( messageDiv.classList.add("mb-2", "message"); messageDiv.setAttribute("data-message-id", messageId || `msg-${Date.now()}`); messageDiv.dataset.conversationId = resolveMessageConversationId(fullMessageObject); + messageDiv.dataset.conversationContentsRole = + sender === "You" || sender === "Collaborator" ? "user" : "other"; + messageDiv.conversationContentsText = + sender === "You" || sender === "Collaborator" ? String(messageContent || "") : ""; let avatarImg = ""; let avatarAltText = ""; diff --git a/application/single_app/templates/admin_settings.html b/application/single_app/templates/admin_settings.html index 0a4f00428..1ef48d85c 100644 --- a/application/single_app/templates/admin_settings.html +++ b/application/single_app/templates/admin_settings.html @@ -8217,6 +8217,31 @@
+ +
+
+ Conversation Contents Drawer +
+

+ Let users navigate long conversations from a list of their prompts. +

+
+ + + +
+

+ When enabled, users can turn the drawer off for their own account from Profile settings. +

+
+ {% if conversation_contents_drawer_enabled %} + + {% endif %}
@@ -790,6 +803,34 @@
Grounded Search
+ {% if conversation_contents_drawer_enabled %} + + {% endif %} @@ -1489,6 +1530,7 @@
Tutorial Preferences
+ {% if app_settings.enable_conversation_contents_drawer %} +
+
+
+ +
+
Conversation Navigation
+

Show a contents drawer in Chat for jumping between your prompts in long conversations.

+

The drawer is enabled by default and can be changed at any time.

+
+
+
+ +
+
+
+ + +
+
+
+ +
+
+ +
+
+ {% endif %} +
@@ -2440,6 +2485,7 @@