Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions application/single_app/static/js/chat/chat-conversations.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,21 @@ async function refreshAgentsAndModelsForActiveConversation() {
await refreshAgentsForActiveConversation();
await refreshModelSelection();
}

function scrollConversationViewToBottom() {
const container = document.getElementById("chat-messages-container") || document.getElementById("chatbox");
if (!container) {
return;
}
// Use animation frames so layout-dependent heights settle first.
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
});
}

let selectionModeActive = false; // Track if selection mode is active
let selectionModeTimer = null; // Timer for auto-hiding checkboxes
let showHiddenConversations = false; // Track if hidden conversations should be shown
Expand Down Expand Up @@ -1670,13 +1685,16 @@ export async function selectConversation(conversationId) {
}

if (isCollaborativeConversation && window.chatCollaboration?.activateConversation) {
await loadMessages(conversationId);
scrollConversationViewToBottom();
await window.chatCollaboration.activateConversation(conversationId, metadata);
} else {
window.chatCollaboration?.deactivateConversation?.();
await loadMessages(conversationId);
try {
const streamingModule = await import('./chat-streaming.js');
await streamingModule.reattachStreamingConversation(conversationId);
scrollConversationViewToBottom();
} catch (error) {
console.warn('Failed to reattach active stream for conversation:', error);
}
Expand Down
70 changes: 66 additions & 4 deletions application/single_app/static/js/chat/chat-global.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,71 @@ let groupPrompts = [];
let publicPrompts = [];
let currentlyEditingId = null;

function getChatScrollContainer() {
return (
document.getElementById("chat-messages-container") ||
document.getElementById("chatbox") ||
null
);
}

function isChatNearBottom(threshold = 40) {
const container = getChatScrollContainer();
if (!container) return true;

const distanceFromBottom =
container.scrollHeight - (container.scrollTop + container.clientHeight);
return distanceFromBottom <= threshold;
}

function scrollChatToBottom() {
const chatbox = document.getElementById("chatbox");
if (chatbox) {
chatbox.scrollTop = chatbox.scrollHeight;
const container = getChatScrollContainer();
if (container) {
container.scrollTop = container.scrollHeight;
}
}
}

function showScrollToBottomButton() {
const btn = document.getElementById("scroll-to-bottom-btn");
if (!btn) return;
btn.classList.remove("d-none");
}

function hideScrollToBottomButton() {
const btn = document.getElementById("scroll-to-bottom-btn");
if (!btn) return;
btn.classList.add("d-none");
}

function initializeChatScrollBehavior() {
const container = getChatScrollContainer();
const btn = document.getElementById("scroll-to-bottom-btn");

if (!container) return;

// Initial state
if (isChatNearBottom()) {
hideScrollToBottomButton();
} else {
showScrollToBottomButton();
}

container.addEventListener("scroll", () => {
if (isChatNearBottom()) {
hideScrollToBottomButton();
} else {
showScrollToBottomButton();
}
});

if (btn) {
btn.addEventListener("click", () => {
scrollChatToBottom();
hideScrollToBottomButton();
});
}
}

window.addEventListener("load", () => {
initializeChatScrollBehavior();
});
55 changes: 51 additions & 4 deletions application/single_app/static/js/chat/chat-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -5117,7 +5117,14 @@ export function appendMessage(
});
}

scrollChatToBottom();
// For AI messages, only auto-scroll if the user is currently near
// the bottom. This prevents a final jump after a long answer if
// the user has scrolled up to read earlier content.
if (typeof isChatNearBottom === 'function' && typeof scrollChatToBottom === 'function') {
if (isChatNearBottom()) {
scrollChatToBottom();
}
}
return; // <<< EXIT EARLY FOR AI MESSAGES

// --- Handle ALL OTHER message types ---
Expand Down Expand Up @@ -5209,7 +5216,10 @@ export function appendMessage(

// Validate image URL before creating img tag
if (messageContent && messageContent !== 'null' && messageContent.trim() !== '') {
messageContentHtml = `<img src="${messageContent}" alt="${isUserUpload ? 'Uploaded' : 'Generated'} Image" class="generated-image" style="width: 170px; height: 170px; cursor: pointer;" data-image-src="${messageContent}" onload="scrollChatToBottom()" onerror="this.src='/static/images/image-error.png'; this.alt='Failed to load image';" />`;
// Use a placeholder container; the actual <img> element will be
// created with DOM APIs after insertion to avoid string-based
// attribute interpolation in src/data-*.
messageContentHtml = '<span class="generated-image-placeholder"></span>';
} else {
messageContentHtml = `<div class="alert alert-warning"><i class="bi bi-exclamation-triangle me-2"></i>Failed to ${isUserUpload ? 'load' : 'generate'} image - invalid response from image service</div>`;
}
Expand Down Expand Up @@ -5399,6 +5409,28 @@ export function appendMessage(
chatbox.appendChild(messageDiv);
hydrateChatWorkspaceAttachmentProgress(messageDiv);

// Attach safe image element and error handler for generated/uploaded images
if (sender === "image") {
const placeholder = messageDiv.querySelector('.generated-image-placeholder');
if (placeholder && messageContent && messageContent !== 'null' && messageContent.trim() !== '') {
const imgEl = document.createElement('img');
imgEl.className = 'generated-image';
imgEl.style.width = '170px';
imgEl.style.height = '170px';
imgEl.style.cursor = 'pointer';
imgEl.src = messageContent;
imgEl.alt = isUserUpload ? 'Uploaded Image' : 'Generated Image';
imgEl.dataset.imageSrc = messageContent;

imgEl.addEventListener('error', () => {
imgEl.src = '/static/images/image-error.png';
imgEl.alt = 'Failed to load image';
});

placeholder.replaceWith(imgEl);
}
}

// Highlight code blocks in the messages
messageDiv.querySelectorAll('pre code[class^="language-"]').forEach((block) => {
const match = block.className.match(/language-([a-zA-Z0-9]+)/);
Expand Down Expand Up @@ -5517,7 +5549,16 @@ export function appendMessage(
}
}

scrollChatToBottom();
// For new user/file/image messages, scroll to bottom once so the
// user sees what they just sent. For history loads, only scroll
// if they are already near the bottom.
if (isNewMessage && typeof scrollChatToBottom === 'function') {
scrollChatToBottom();
} else if (typeof isChatNearBottom === 'function' && typeof scrollChatToBottom === 'function') {
if (isChatNearBottom()) {
scrollChatToBottom();
}
}
} // End of the large 'else' block for non-AI messages
}

Expand Down Expand Up @@ -5568,6 +5609,12 @@ export function sendMessage() {
updateSendButtonVisibility();
// Keep focus on input
userInput.focus();

// After sending, ensure the chat view scrolls so the
// user can see their newly submitted message.
if (typeof window.scrollChatToBottom === 'function') {
window.scrollChatToBottom();
}
}

function getCurrentModelSelection() {
Expand Down Expand Up @@ -6216,7 +6263,7 @@ export function actuallySendMessage(finalMessageToSend) {
}

const pendingCollaborativeContext = window.chatCollaboration?.getPendingMessageContext?.({ invocationTarget }) || null;
appendMessage("You", displayMessageText, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext);
appendMessage("You", displayMessageText, null, tempUserMessageId, false, [], [], [], null, null, pendingCollaborativeContext, true);
userInput.value = "";
userInput.style.height = "";
updateSendButtonVisibility();
Expand Down
16 changes: 14 additions & 2 deletions application/single_app/templates/chats.html
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,20 @@ <h5 id="current-conversation-title" class="mb-0 flex-grow-1 text-truncate">
</div>
</div>

<div id="chatbox" class="flex-grow-1 p-3" style="overflow-y: auto;">
<!-- Chat messages will be dynamically loaded here -->
<div id="chat-messages-container" class="position-relative flex-grow-1" style="overflow-y: auto;">
<div id="chatbox" class="flex-grow-1 p-3">
<!-- Chat messages will be dynamically loaded here -->
</div>
<button
id="scroll-to-bottom-btn"
type="button"
class="btn btn-primary rounded-circle position-sticky d-none"
style="margin-left: auto; bottom: 1rem; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;"
aria-label="Scroll to latest message"
title="Scroll to latest message"
>
<i class="bi bi-arrow-down"></i>
</button>
</div>

<div class="p-3 border-top flex-shrink-0">
Expand Down
10 changes: 10 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver

### **(v0.250.005)**

#### New Features

* **Chat Scroll Behavior and 508 Usability**
* Updated chat message rendering so the viewport no longer jumps to the very bottom of long assistant responses when they finish loading while the user is reading near the top.
* Auto-scroll now only occurs when the user is already near the bottom of the conversation, and a floating "scroll to latest message" button appears when new content arrives below the current view.
* This aligns the chat experience more closely with other AI chat tools and reduces unexpected motion for 508 testers and keyboard users.
* (Ref: `chats.html`, `chat-global.js`, `chat-messages.js`)

### **(v0.241.007)**

#### Bug Fixes

* **Admin Settings Save 500 Fix**
Expand Down
Loading