Skip to content

Commit d091aa6

Browse files
committed
smoother auto-scroll handling
1 parent 1b03f1e commit d091aa6

File tree

3 files changed

+44
-14
lines changed

3 files changed

+44
-14
lines changed

tools/server/public/index.html.gz

139 Bytes
Binary file not shown.

tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
let showFileErrorDialog = $state(false);
4949
let uploadedFiles = $state<ChatUploadedFile[]>([]);
5050
let userScrolledUp = $state(false);
51+
let lastPinnedMessageCount = $state(0);
52+
let lastPinnedTailId = $state<string | null>(null);
5153
5254
let fileErrorData = $state<{
5355
generallyUnsupported: File[];
@@ -206,14 +208,22 @@
206208
}
207209
}
208210
209-
function handleScroll() {
211+
function handleScroll(event?: Event) {
210212
if (disableAutoScroll || !chatScrollContainer) return;
211213
214+
// Ignore programmatic scroll events (e.g. our own scrollTo calls) so we only
215+
// disable auto-scroll based on user intent.
216+
if (event && 'isTrusted' in event && !(event as Event).isTrusted) {
217+
lastScrollTop = chatScrollContainer.scrollTop;
218+
return;
219+
}
220+
212221
const { scrollTop, scrollHeight, clientHeight } = chatScrollContainer;
213222
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
214223
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
215224
216-
if (scrollTop < lastScrollTop && !isAtBottom) {
225+
// Any user-driven upward scroll disables auto-scroll, even if they were close to the bottom.
226+
if (scrollTop < lastScrollTop) {
217227
userScrolledUp = true;
218228
autoScrollEnabled = false;
219229
} else if (isAtBottom && userScrolledUp) {
@@ -350,10 +360,21 @@
350360
351361
// Keep view pinned to bottom across message merges while auto-scroll is enabled.
352362
$effect(() => {
353-
void liveMessages;
354-
if (!disableAutoScroll && autoScrollEnabled) {
355-
queueMicrotask(() => scrollChatToBottom('instant'));
356-
}
363+
const messageCount = liveMessages.length;
364+
const tailId = liveMessages[messageCount - 1]?.id ?? null;
365+
const shouldPinNow = messageCount !== lastPinnedMessageCount || tailId !== lastPinnedTailId;
366+
367+
lastPinnedMessageCount = messageCount;
368+
lastPinnedTailId = tailId;
369+
370+
if (!shouldPinNow) return;
371+
if (disableAutoScroll || userScrolledUp || !autoScrollEnabled) return;
372+
373+
queueMicrotask(() => {
374+
// Re-check at execution time so user scroll actions can "win" even if a pin was queued earlier.
375+
if (disableAutoScroll || userScrolledUp || !autoScrollEnabled) return;
376+
scrollChatToBottom('instant');
377+
});
357378
});
358379
</script>
359380

tools/server/webui/src/lib/stores/chat.svelte.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,6 @@ class ChatStore {
622622
await conversationsStore.updateCurrentNode(assistantMessage.id);
623623

624624
if (onComplete) await onComplete(streamedContent);
625-
this.setChatLoading(assistantMessage.convId, false);
626625
this.clearChatStreaming(assistantMessage.convId);
627626
this.clearProcessingState(assistantMessage.convId);
628627

@@ -632,12 +631,20 @@ class ChatStore {
632631

633632
// If the model emitted tool calls, execute any enabled tools and continue the exchange.
634633
if (finalToolCalls) {
635-
await this.processToolCallsAndContinue(
634+
const continued = await this.processToolCallsAndContinue(
636635
finalToolCalls,
637636
assistantMessage,
638637
modelOverride || null
639638
);
639+
// Keep the chat "loading" state continuous across tool execution + follow-up generation.
640+
// If we didn't actually continue, make sure we clear the loading state now.
641+
if (!continued) {
642+
this.setChatLoading(assistantMessage.convId, false);
643+
}
644+
return;
640645
}
646+
647+
this.setChatLoading(assistantMessage.convId, false);
641648
},
642649
onError: (error: Error) => {
643650
this.stopStreaming();
@@ -1408,7 +1415,7 @@ class ChatStore {
14081415
toolCallContent: string,
14091416
sourceAssistant: DatabaseMessage,
14101417
modelOverride?: string | null
1411-
): Promise<void> {
1418+
): Promise<boolean> {
14121419
const currentConfig = config();
14131420

14141421
let toolCalls: ApiChatCompletionToolCall[] = [];
@@ -1419,17 +1426,17 @@ class ChatStore {
14191426
}
14201427
} catch (error) {
14211428
console.warn('Failed to parse tool calls', error);
1422-
return;
1429+
return false;
14231430
}
14241431

14251432
const relevantCalls = toolCalls.filter((call) => {
14261433
const fnName = call.function?.name;
14271434
return Boolean(fnName && call.id && isToolEnabled(fnName, currentConfig));
14281435
});
1429-
if (relevantCalls.length === 0) return;
1436+
if (relevantCalls.length === 0) return false;
14301437

14311438
const activeConv = conversationsStore.activeConversation;
1432-
if (!activeConv) return;
1439+
if (!activeConv) return false;
14331440

14341441
const toolMessages: DatabaseMessage[] = [];
14351442

@@ -1462,13 +1469,13 @@ class ChatStore {
14621469
}
14631470
}
14641471

1465-
if (toolMessages.length === 0) return;
1472+
if (toolMessages.length === 0) return false;
14661473

14671474
// Create a new assistant message to continue the conversation
14681475
const newAssistant = await this.createAssistantMessage(
14691476
toolMessages[toolMessages.length - 1]?.id || sourceAssistant.id
14701477
);
1471-
if (!newAssistant) return;
1478+
if (!newAssistant) return false;
14721479

14731480
conversationsStore.addMessageToActive(newAssistant);
14741481
this.setChatLoading(activeConv.id, true);
@@ -1481,6 +1488,8 @@ class ChatStore {
14811488
undefined,
14821489
modelOverride || null
14831490
);
1491+
1492+
return true;
14841493
}
14851494

14861495
// ─────────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)