Files
llama.cpp-xrip/tools/ui/src/lib/components/app/content/SyntaxHighlightedCode.svelte
T
Aleksander Grygier 8ab29faec4 ui: keep chats at the bottom until the reader scrolls up (#31311)
* ui: land at the bottom of a conversation every time it opens

Opening a long conversation often stopped short of its end:

- the press grace counted from page load (pressedAt = 0), so a chat that
  landed within 400 ms of a refresh did not follow the rows realizing
  after the jump
- the click that opened a chat (sidebar row, tab) started the same grace,
  so navigating by click had the same problem
- any scroll event away from the bottom stopped following, including the
  ones the browser fires itself while the chat settles
- the chat screen outlives its routes, so going back to the last landed
  conversation through the new-chat screen skipped the landing entirely

scrollToBottom() now clears the grace, only an upward scroll away from the
bottom stops following, and every navigation lands anew. Navigations into
a chat also disable SvelteKit's scroll reset, which moved the previous
conversation to its top for a few frames.

Where overflow-anchor is supported, a 1px pin after the messages is the
only scroll anchor while following, so the browser keeps the bottom in
place itself; WebKit keeps the resize observer.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: let nested agentic content scrollers chain to the chat

Reasoning, shell output and code blocks used overscroll-behavior:
contain, so the wheel stopped dead at their edges instead of
scrolling the conversation.

Assisted-by: Claude (Opus 5.5 Medium)

* ui: stop following the stream as soon as the reader scrolls up

A trackpad or smooth wheel scrolls in steps smaller than the at-bottom
threshold, so each step up was re-stuck and snapped back on the next
growth while a response streamed. Any upward move that is not content
shrinking now releases at once.

The attach effect also read the hook's own state, so every flip
re-attached and re-measured, which undid such a release.

Assisted-by: Claude (Opus 5.5 Medium)
2026-10-29 10:18:13 +02:00

165 lines
4.2 KiB
Svelte

<script lang="ts">
import { browser } from '$app/environment';
import { SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString, ColorMode } from '$lib/enums';
import { highlightCode } from '$lib/utils';
import githubLightCss from 'highlight.js/styles/github.css?inline';
import githubDarkCss from 'highlight.js/styles/github-dark.css?inline';
import { mode } from 'mode-watcher';
interface Props {
code: string;
language?: string;
class?: string;
maxHeight?: string;
maxWidth?: string;
/** Auto-scrolls to the bottom of new chunks; pauses on user scroll-up
* until the user returns to the bottom. */
streaming?: boolean;
}
let {
class: className = '',
code,
language = 'text',
maxHeight = '60vh',
maxWidth = '',
streaming = false
}: Props = $props();
const highlightedHtml = $derived(highlightCode(code, language));
let scrollEl = $state<HTMLDivElement>();
let userScrolledUp = $state(false);
let lastScrollTop = 0;
const SCROLL_BOTTOM_THRESHOLD_PX = SYNTAX_CODE_SCROLL_AT_BOTTOM_THRESHOLD_PX;
let pendingFrame: number | null = null;
function loadHighlightTheme(isDark: boolean) {
if (!browser) return;
const existingThemes = document.querySelectorAll(
`style[${UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW}]`
);
existingThemes.forEach((style) => style.remove());
const style = document.createElement('style');
style.setAttribute(UI_DATA_ATTRS.HIGHLIGHT_THEME_PREVIEW, BooleanString.TRUE);
style.textContent = isDark ? githubDarkCss : githubLightCss;
document.head.appendChild(style);
}
function isAtBottom(): boolean {
if (!scrollEl) return false;
return (
scrollEl.scrollHeight - scrollEl.clientHeight - scrollEl.scrollTop <=
SCROLL_BOTTOM_THRESHOLD_PX
);
}
function scrollToBottomOnFrame() {
if (pendingFrame !== null || !scrollEl || userScrolledUp) return;
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null;
// User may scroll between scheduling and paint.
if (scrollEl && !userScrolledUp) {
scrollEl.scrollTop = scrollEl.scrollHeight;
}
});
}
function handleScrollEvent() {
if (!scrollEl) return;
const isScrollingUp = scrollEl.scrollTop < lastScrollTop;
if (isScrollingUp && !isAtBottom()) {
userScrolledUp = true;
} else if (isAtBottom()) {
userScrolledUp = false;
}
lastScrollTop = scrollEl.scrollTop;
}
$effect(() => {
const currentMode = mode.current;
const isDark = currentMode === ColorMode.DARK;
loadHighlightTheme(isDark);
});
// Pin to bottom at the start of each streaming episode.
$effect(() => {
if (streaming) {
userScrolledUp = false;
lastScrollTop = 0;
}
});
$effect(() => {
void code;
if (!streaming || userScrolledUp) return;
scrollToBottomOnFrame();
});
// Layout shifts that don't change `code` (highlight.js re-tokenize, line-wrap reflow).
$effect(() => {
if (!streaming || !scrollEl) return;
const observer = new MutationObserver(() => scrollToBottomOnFrame());
observer.observe(scrollEl, {
characterData: true,
childList: true,
subtree: true
});
return () => observer.disconnect();
});
</script>
<div
bind:this={scrollEl}
class="code-preview-wrapper min-w-0 max-w-full overflow-auto rounded-xl border shadow-[0_1px_2px_0_rgb(0_0_0_/_0.05)] {className}"
onscroll={handleScrollEvent}
style="border-color: color-mix(in oklch, var(--border) 30%, transparent); background: var(--code-background); max-height: {maxHeight}; {maxWidth
? `max-width: ${maxWidth};`
: ''}"
>
<!-- Single line: hljs injection depends on a contiguous source string. -->
<pre class="m-0"><code class="hljs text-sm leading-relaxed">{@html highlightedHtml}</code></pre>
</div>
<style>
.code-preview-wrapper pre {
background: transparent;
padding: 0;
/* Stretch to the content's intrinsic width so a single long line
propagates its overflow to this scrolling wrapper instead of being
clipped at the wrapper's width with no way to scroll to it. */
width: max-content;
min-width: 100%;
}
.code-preview-wrapper code {
background: transparent;
display: block;
padding: 0.5rem;
width: max-content;
min-width: 100%;
}
:global(.dark) .code-preview-wrapper {
border-color: color-mix(in oklch, var(--border) 20%, transparent);
}
</style>