From 1ec298e84b5b5c62bb1049c2e47833be08ac8b71 Mon Sep 17 00:00:00 2001 From: Jicheng Lu <103353@smsassist.com> Date: Mon, 31 Aug 2026 15:39:58 -0500 Subject: [PATCH] Stop socket messages from hijacking scroll; add jump button and message index Chat thread and both log panes no longer auto-scroll when messages or log entries arrive over the socket. A panel follows its tail only while the user asked it to - by sending a message, or pressing the new jump button - and scrolling away cancels that. The button shows pulsing dots while a reply is in flight and a chevron once it lands, and is clickable in both states. Also adds a ChatGPT-style index rail beside the thread: one tick per user message, hover previews the turn, clicking scrolls there and points the log panes at the same message. Content log blocks now toggle collapse on a click anywhere in the block, alongside the existing More/Less button. Includes the pending agent rule item change: Message renamed to Initial Message and moved below the criteria rows, with a vertically resizable textarea. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/styles/pages/_agent.scss | 3 +- src/lib/styles/pages/_chat.scss | 338 ++++++++++++++++++ .../[conversationId]/chat-box.svelte | 260 +++++++++++++- .../persist-log/content-log-element.svelte | 29 +- .../persist-log/persist-log.svelte | 123 ++++++- .../rules/agent-rule-item.svelte | 45 +-- 6 files changed, 755 insertions(+), 43 deletions(-) diff --git a/src/lib/styles/pages/_agent.scss b/src/lib/styles/pages/_agent.scss index e58df430..ccfbd4c3 100644 --- a/src/lib/styles/pages/_agent.scss +++ b/src/lib/styles/pages/_agent.scss @@ -2144,7 +2144,8 @@ $panel-radius: 0.5rem; background-color: rgb(255 255 255); border: 1px solid rgb(209 213 219); border-radius: 0.375rem; - resize: none; + resize: vertical; + min-height: 2.25rem; transition: border-color 0.15s ease, box-shadow 0.15s ease; &:focus { diff --git a/src/lib/styles/pages/_chat.scss b/src/lib/styles/pages/_chat.scss index b4273de7..ee0ddff6 100644 --- a/src/lib/styles/pages/_chat.scss +++ b/src/lib/styles/pages/_chat.scss @@ -1333,6 +1333,256 @@ } +/* Message area wrapper: holds the scroller plus the index rail that sits + outside it, so the rail stays put while the thread moves. */ + + +.cb-msgs-area { + position: relative; +} + + +.cb-msgs-area > .cb-msgs-scroll { + height: 100%; +} + + +/* ===== Conversation index rail ===== */ + + +.cb-msg-index { + position: absolute; + left: 6px; + top: 50%; + transform: translateY(-50%); + z-index: 4; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; + max-height: 80%; + padding: 4px 2px; + overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + + +.cb-msg-index-tick { + position: relative; + display: flex; + align-items: center; + padding: 2px 0; + border: 0; + background: none; + cursor: pointer; + line-height: 0; +} + + +.cb-msg-index-bar { + display: block; + width: 14px; + height: 2px; + border-radius: 2px; + background-color: rgb(203 213 225); + transition: width 0.15s ease, background-color 0.15s ease; +} + + +.cb-msg-index-tick:hover .cb-msg-index-bar, +.cb-msg-index-tick:focus-visible .cb-msg-index-bar { + width: 20px; + background-color: var(--color-primary, #556ee6); +} + + +.cb-msg-index-tick-active .cb-msg-index-bar { + width: 20px; + background-color: rgb(100 116 139); +} + + +.cb-msg-index-tick:focus-visible { + outline: none; +} + + +/* Hover preview: the question, then the opening of the answer it got. */ + + +.cb-msg-index-preview { + /* Fixed, and positioned from JS: the rail scrolls, and a scrollable box would + clip a preview drawn outside it. */ + position: fixed; + transform: translateY(-50%); + z-index: 1050; + display: flex; + flex-direction: column; + gap: 4px; + width: max-content; + max-width: 280px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgb(229 231 235); + background-color: rgb(255 255 255); + box-shadow: 0 6px 20px rgb(0 0 0 / 14%); + text-align: left; + line-height: 1.35; + pointer-events: none; +} + + +.cb-msg-index-preview-user { + font-size: 12px; + font-weight: 600; + color: rgb(31 41 55); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; +} + + +.cb-msg-index-preview-reply { + font-size: 11.5px; + color: rgb(107 114 128); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; +} + + +.dark .cb-msg-index-bar { + background-color: rgb(75 85 99); +} + + +.dark .cb-msg-index-tick-active .cb-msg-index-bar { + background-color: rgb(156 163 175); +} + + +.dark .cb-msg-index-preview { + background-color: rgb(31 41 55); + border-color: rgb(55 65 81); +} + + +.dark .cb-msg-index-preview-user { + color: rgb(229 231 235); +} + + +.dark .cb-msg-index-preview-reply { + color: rgb(156 163 175); +} + + +/* "Jump to latest" affordance. The strip has no height of its own so the + button floats over the tail of the conversation rather than pushing it up. */ + + +.cb-jump-strip { + position: sticky; + bottom: 12px; + height: 0; + display: flex; + justify-content: center; + pointer-events: none; + z-index: 3; +} + + +.cb-jump-btn { + pointer-events: auto; + transform: translateY(-100%); + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: 50%; + border: 1px solid var(--bs-border-color, rgb(0 0 0 / 12%)); + background-color: var(--bs-body-bg, #fff); + color: var(--bs-body-color, #495057); + box-shadow: 0 2px 8px rgb(0 0 0 / 18%); + cursor: pointer; + font-size: 20px; + line-height: 1; + transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease; + + &:hover { + background-color: var(--bs-primary, #556ee6); + color: #fff; + transform: translateY(-100%) scale(1.06); + } + + &:focus-visible { + outline: 2px solid var(--bs-primary, #556ee6); + outline-offset: 2px; + } +} + + +/* In-flight state: three pulsing dots in place of the arrow. */ + + +.cb-jump-btn-waiting { + color: var(--bs-primary, #556ee6); +} + + +.cb-jump-dots { + display: flex; + align-items: center; + gap: 3px; + + & > span { + width: 4px; + height: 4px; + border-radius: 50%; + background-color: currentcolor; + animation: cb-jump-dot-pulse 1.2s ease-in-out infinite; + } + + & > span:nth-child(2) { + animation-delay: 0.15s; + } + + & > span:nth-child(3) { + animation-delay: 0.3s; + } +} + + +@keyframes cb-jump-dot-pulse { + 0%, + 80%, + 100% { + opacity: 0.3; + transform: translateY(0); + } + + 40% { + opacity: 1; + transform: translateY(-2px); + } +} + + +@media (prefers-reduced-motion: reduce) { + .cb-jump-dots > span { + animation: none; + opacity: 0.75; + } +} + + /* Conversation container (replaces .chat-conversation.p-3) */ @@ -3602,6 +3852,12 @@ } +/* Whole collapsible block is a toggle target; the More/Less button stays. */ +.cle-content-clickable { + cursor: pointer; +} + + /* More/Less toggle (replaces .btn.btn-link.toggle-btn.btn-sm) */ @@ -3882,6 +4138,88 @@ } +/* "Jump to latest" affordance, mirroring the chat thread's. The strip has no + height of its own so the button floats over the tail of the log. */ + + +.pl-jump-strip { + position: sticky; + bottom: 10px; + height: 0; + display: flex; + justify-content: center; + pointer-events: none; + z-index: 3; +} + + +.pl-jump-btn { + pointer-events: auto; + transform: translateY(-100%); + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + border: 1px solid color-mix(in srgb, white 16%, transparent); + background-color: rgb(17 24 42); + color: rgb(226 232 240); + box-shadow: 0 2px 10px rgb(0 0 0 / 45%); + cursor: pointer; + font-size: 18px; + line-height: 1; + transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease; + + &:hover { + background-color: var(--color-primary, #556ee6); + color: rgb(255 255 255); + transform: translateY(-100%) scale(1.06); + } + + &:focus-visible { + outline: 2px solid var(--color-primary, #556ee6); + outline-offset: 2px; + } +} + + +.pl-jump-btn-waiting { + color: var(--color-primary, #556ee6); +} + + +.pl-jump-dots { + display: flex; + align-items: center; + gap: 3px; + + & > span { + width: 4px; + height: 4px; + border-radius: 50%; + background-color: currentcolor; + animation: cb-jump-dot-pulse 1.2s ease-in-out infinite; + } + + & > span:nth-child(2) { + animation-delay: 0.15s; + } + + & > span:nth-child(3) { + animation-delay: 0.3s; + } +} + + +@media (prefers-reduced-motion: reduce) { + .pl-jump-dots > span { + animation: none; + opacity: 0.75; + } +} + + /* Hide inactive tab pane (replaces global .hide utility) */ diff --git a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte index d5c54917..bb0c6050 100644 --- a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte +++ b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte @@ -148,6 +148,17 @@ /** @type {any[]} */ let scrollbars = $state([]); + /** Within this many px of the bottom the thread counts as "at the bottom". */ + const BOTTOM_THRESHOLD_PX = 80; + let isPinnedToBottom = $state(true); + /* + * Incoming socket messages never move the viewport on their own. They only keep + * it at the bottom while the user has explicitly asked to follow along — by + * sending a message, or by pressing the jump button (including while a reply is + * still streaming, which is the point of it being clickable in that state). + * Scrolling away from the bottom cancels the follow. + */ + let followStream = $state(false); /** @type {import('$conversationTypes').ConversationModel} */ let conversation = $state(/** @type {any} */ (undefined)); @@ -327,8 +338,85 @@ return () => clearInterval(timer); }); + /* + * Index rail: one tick per user message, in order. The preview pairs what the + * user asked with the start of the reply it got, which is what makes a tick + * recognisable — the question alone is often the same few words. + */ + let messageIndex = $derived.by(() => { + /** @type {{ id: string, ordinal: number, text: string, reply: string }[]} */ + const entries = []; + dialogs.forEach((msg, idx) => { + if (BOT_SENDERS.includes(msg?.sender?.role || '') || !msg?.message_id) return; + + const reply = dialogs.slice(idx + 1).find(x => BOT_SENDERS.includes(x?.sender?.role || '')); + entries.push({ + id: msg.message_id, + ordinal: entries.length + 1, + text: _.trim(msg.text || '') || '(no text)', + reply: _.trim(reply?.rich_content?.message?.text || reply?.text || '') + }); + }); + return entries; + }); + + let activeIndexId = $state(''); + /* + * The preview is a single fixed-position node rather than a child of each tick: + * the rail scrolls when a conversation has many turns, and any scrollable box + * clips what its children paint outside it, which hid the preview entirely. + */ + /** @type {{ entry: any, top: number, left: number } | null} */ + let indexPreview = $state(null); + + /** + * @param {any} entry + * @param {EventTarget | null} target + */ + function showIndexPreview(entry, target) { + const rect = /** @type {HTMLElement} */ (target)?.getBoundingClientRect?.(); + if (!rect) return; + indexPreview = { + entry, + top: rect.top + rect.height / 2, + left: rect.right + 10 + }; + } + + function hideIndexPreview() { + indexPreview = null; + } + + /** + * Jump the thread to a user message, and take the log panes with it — the same + * thing clicking the bubble does, so the rail is a shortcut to that, not a + * second behaviour. + * @param {string} messageId + */ + function goToUserMessage(messageId) { + const scrollbar = scrollbars[0]; + const target = document.querySelector(`#user-msg-${messageId}`); + if (scrollbar && target) { + const { viewport } = scrollbar.elements(); + // Offset by a bit so the message is not flush against the top edge. + const top = viewport.scrollTop + target.getBoundingClientRect().top + - viewport.getBoundingClientRect().top - 16; + viewport.scrollTo({ top, behavior: 'smooth' }); + // A jump to history is a deliberate move away from the tail. + followStream = false; + } + activeIndexId = messageId; + directToLog(messageId); + } + + /* + * Consumers (rich content options, the file gallery) call this when content + * they own appears — which for a bot reply means it fires right after a socket + * message. So it is deliberately NOT forced: it follows the thread only while + * the user is already at the bottom. + */ setContext('chat-window-context', { - autoScrollToBottom: autoScrollToBottom + autoScrollToBottom: () => autoScrollToBottom() }); onDestroy(() => { @@ -392,9 +480,35 @@ if (msgScrollElem) { // @ts-ignore scrollbars = [OverlayScrollbars(msgScrollElem, options)]; + trackBottomProximity(); } } + /** + * New messages only pull the thread down while the user is already reading + * the bottom of it. Once they scroll up, auto-scroll stops fighting them and + * the "jump to latest" button takes over. + */ + function trackBottomProximity() { + const scrollbar = scrollbars[0]; + if (!scrollbar) return; + + const { viewport } = scrollbar.elements(); + const update = () => { + const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + isPinnedToBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX; + if (!isPinnedToBottom) { + followStream = false; + } + updateActiveIndex(viewport); + }; + update(); + viewport.addEventListener('scroll', update, { passive: true }); + // Growing content moves the bottom away without firing a scroll event, so the + // button would stay hidden while the thread quietly scrolls out of reach. + new ResizeObserver(update).observe(viewport.firstElementChild || viewport); + } + function handleLogoutAction() { resetStorage(true); } @@ -571,8 +685,55 @@ } } + /** + * The rail tracks the user messages themselves: the active tick is the last one + * whose bubble has come into view. Anchoring on the top edge instead marked the + * previous turn while its successor's question was plainly on screen. + * @param {HTMLElement} viewport + */ + function updateActiveIndex(viewport) { + const viewportBottom = viewport.getBoundingClientRect().bottom; + let current = ''; + for (const entry of messageIndex) { + const el = document.querySelector(`#user-msg-${entry.id}`); + if (!el) continue; + // Ask for a little more than a sliver so a bubble just cresting the bottom + // edge does not steal the highlight from the turn being read. + if (el.getBoundingClientRect().top <= viewportBottom - 40) { + current = entry.id; + } else { + break; + } + } + const next = current || messageIndex[0]?.id || ''; + if (next === activeIndexId) return; + + activeIndexId = next; + // A long conversation makes the rail itself scroll, so keep the active tick + // inside it — otherwise the highlight is somewhere off the rail's own view. + requestAnimationFrame(() => { + const rail = document.querySelector('.cb-msg-index'); + const tick = document.querySelector('.cb-msg-index-tick-active'); + if (!rail || !tick || rail.scrollHeight <= rail.clientHeight) return; + + const railRect = rail.getBoundingClientRect(); + const tickRect = tick.getBoundingClientRect(); + if (tickRect.top < railRect.top || tickRect.bottom > railRect.bottom) { + tick.scrollIntoView({ block: 'nearest' }); + } + }); + } + let _autoScrollScheduled = false; - function autoScrollToBottom() { + /** + * @param {boolean} force Scroll even when the user has scrolled away from the + * bottom — used by the explicit "jump to latest" button, never by new messages. + */ + function autoScrollToBottom(force = false) { + if (force) { + followStream = true; + } + if (!force && !isPinnedToBottom) return; if (_autoScrollScheduled) return; _autoScrollScheduled = true; requestAnimationFrame(() => { @@ -582,6 +743,7 @@ const { viewport } = scrollbar.elements(); viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth' }); }); + isPinnedToBottom = true; _autoScrollScheduled = false; }; scrollToBottom(); @@ -714,7 +876,7 @@ ...message, is_chat_message: true }); - refresh(); + refresh(!followStream); text = ""; } @@ -745,7 +907,7 @@ isStreaming = false; latestStateLog = message.states; - refresh(); + refresh(!followStream); if (isFrame) { window.parent.postMessage(message, "*"); @@ -769,7 +931,7 @@ }); } - refresh(); + refresh(!followStream); if (isFrame) { window.parent.postMessage(message, "*"); @@ -792,7 +954,7 @@ } }); } - refresh(); + refresh(!followStream); } @@ -815,7 +977,7 @@ } dialogs[dialogs.length - 1].text += message.text; refreshDialogs(); - autoScrollToBottom(); + if (followStream) autoScrollToBottom(); }, 0); } } else { @@ -851,7 +1013,7 @@ for (const tt of thinkingText) { dialogs[dialogs.length - 1].thought.thinking_text += tt; refreshDialogs(); - autoScrollToBottom(); + if (followStream) autoScrollToBottom(); await delay(10); } } @@ -859,7 +1021,7 @@ for (const char of item.text) { dialogs[dialogs.length - 1].text += char; refreshDialogs(); - autoScrollToBottom(); + if (followStream) autoScrollToBottom(); await delay(10); } } catch (err) { @@ -872,7 +1034,7 @@ /** @param {import('$conversationTypes').ChatResponseModel} message */ function afterReceiveLlmStreamMessage(message) { isStreaming = false; - refresh(); + refresh(!followStream); } function stopStreaming() { @@ -1045,6 +1207,9 @@ */ async function sendChatMessage(msgText, data = null, conversationId = null) { isSendingMsg = true; + // Sending is a deliberate action by the user, so it still jumps the thread + // down — unlike incoming socket messages, which never move the viewport. + autoScrollToBottom(true); resetProgress(); clearInstantLogs(); renewUserSentMessages(msgText); @@ -2188,7 +2353,47 @@ cancel={() => toggleUserAddStateModal()} /> -
+
+ + {#if messageIndex.length > 1} + + {/if} + + {#if indexPreview} +
+ {indexPreview.entry.text} + {#if indexPreview.entry.reply} + {indexPreview.entry.reply} + {/if} +
+ {/if} + +
    {#each Object.entries(groupedDialogs) as [createDate, dialogGroup]} @@ -2608,6 +2813,38 @@ /> {/if}
+ + +
+ {#if !isPinnedToBottom} + + + {/if} +
+
@@ -2732,6 +2969,7 @@ bind:contentLogs={contentLogs} bind:convStateLogs={convStateLogs} bind:autoScroll={autoScrollLog} + isWaiting={isWaiting} closeWindow={() => closePersistLog()} cleanScreen={() => cleanPersistLogScreen()} /> diff --git a/src/routes/chat/[agentId]/[conversationId]/persist-log/content-log-element.svelte b/src/routes/chat/[agentId]/[conversationId]/persist-log/content-log-element.svelte index f39dc77a..84d3785d 100644 --- a/src/routes/chat/[agentId]/[conversationId]/persist-log/content-log-element.svelte +++ b/src/routes/chat/[agentId]/[conversationId]/persist-log/content-log-element.svelte @@ -48,12 +48,29 @@ return ''; }); + let isCollapsible = $derived(collapsedSources.includes(data.source) && isOverflowing); + /** @param {any} e */ function toggleText(e) { e.preventDefault(); is_collapsed = !is_collapsed; } + /** + * Toggle by clicking anywhere in the block, while leaving normal + * interactions intact: text selection, links and nested controls. + * @param {MouseEvent} e + */ + function handleContentClick(e) { + if (!isCollapsible) return; + + const target = /** @type {HTMLElement | null} */ (e.target); + if (target?.closest('a, button, input, textarea, select')) return; + if (window.getSelection()?.toString()) return; + + is_collapsed = !is_collapsed; + } + $effect(() => { void data?.content; if (!contentEl || !collapsedSources.includes(data.source)) { @@ -95,15 +112,21 @@ {`${utcToLocal(data?.created_at, 'hh:mm:ss.SSS A, MMM DD YYYY')} `}
-
+ + +
- {#if collapsedSources.includes(data.source) && isOverflowing} + {#if isCollapsible} diff --git a/src/routes/chat/[agentId]/[conversationId]/persist-log/persist-log.svelte b/src/routes/chat/[agentId]/[conversationId]/persist-log/persist-log.svelte index e0f87077..943a2883 100644 --- a/src/routes/chat/[agentId]/[conversationId]/persist-log/persist-log.svelte +++ b/src/routes/chat/[agentId]/[conversationId]/persist-log/persist-log.svelte @@ -32,6 +32,7 @@ * contentLogs?: import('$conversationTypes').ConversationContentLogModel[], * convStateLogs?: import('$conversationTypes').ConversationStateLogModel[], * autoScroll?: boolean, + * isWaiting?: boolean, * closeWindow: () => void, * cleanScreen: () => void * }} @@ -40,6 +41,7 @@ contentLogs = $bindable([]), convStateLogs = $bindable([]), autoScroll = $bindable(false), + isWaiting = false, closeWindow, cleanScreen } = $props(); @@ -49,6 +51,19 @@ /** @type {number} */ let selectedTab = $state(contentLogTab); + /* + * Same rule as the chat thread: incoming log entries never move a panel on + * their own. A panel follows the tail only while the user asked it to — by + * pressing its jump button — and scrolling away from the bottom cancels that. + * Indexes match `scrollbarElements`: 0 = content log, 1 = conversation states. + */ + const BOTTOM_THRESHOLD_PX = 60; + let isPinnedToBottom = $state([true, true]); + let followTail = $state([false, false]); + + let activeIndex = $derived(selectedTab === contentLogTab ? 0 : 1); + let showJumpButton = $derived(!isPinnedToBottom[activeIndex]); + /** @type {import('$conversationTypes').ConversationLogFilter} */ let contentLogFilter = { size: 100, startTime: utcNow }; /** @type {import('$conversationTypes').ConversationLogFilter} */ @@ -75,6 +90,7 @@ await Promise.all([getChatContentLogs(), getChatStateLogs()]); await tick(); initScrollbars(); + trackBottomProximity(); pinToBottomWhileSettling(); })(); @@ -84,17 +100,47 @@ }); $effect(() => { - // Re-run whenever autoScroll or logs change + // Re-run whenever autoScroll or logs change. Only panels the user put in + // follow mode are moved; the rest stay where they were left. contentLogs; convStateLogs; if (autoScroll) { - scroll(); + followTail.forEach((following, idx) => { + if (following) { + scroll(false, idx); + } + }); } }); + /** Keep `isPinnedToBottom` in step with where the user has scrolled each panel. */ + function trackBottomProximity() { + scrollbars.forEach((scrollbar, idx) => { + if (!scrollbar) return; + + const { viewport } = scrollbar.elements(); + const update = () => { + const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; + const atBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX; + isPinnedToBottom[idx] = atBottom; + if (!atBottom) { + followTail[idx] = false; + } + }; + update(); + viewport.addEventListener('scroll', update, { passive: true }); + // Growing content moves the bottom away without firing a scroll event, so + // the button would stay hidden while the tail slips out of reach. + new ResizeObserver(update).observe(viewport.firstElementChild || viewport); + }); + } + let _scrollScheduled = false; - /** @param {boolean} goToTop */ - function scroll(goToTop = false) { + /** + * @param {boolean} goToTop + * @param {number | null} index Panel to scroll, or null for every panel. + */ + function scroll(goToTop = false, index = null) { if (_scrollScheduled) { return; } @@ -102,15 +148,26 @@ requestAnimationFrame(() => { setTimeout(() => { // @ts-ignore - scrollbars.forEach(scrollbar => { + scrollbars.forEach((scrollbar, idx) => { + if (index !== null && idx !== index) return; + const { viewport } = scrollbar.elements(); viewport.scrollTo({ top: goToTop ? 0 : viewport.scrollHeight, behavior: 'smooth' }); + if (!goToTop) { + isPinnedToBottom[idx] = true; + } }); _scrollScheduled = false; }, 150); }); } + /** The jump button: go to the tail of the visible panel and follow it from there. */ + function jumpToBottom() { + followTail[activeIndex] = true; + scroll(false, activeIndex); + } + /** * Keep each log panel pinned to the very bottom while the initial layout * settles. Async content can grow the row heights after first paint, so a @@ -256,7 +313,7 @@ data-bs-toggle="tooltip" data-bs-placement="top" title="Scroll to bottom" - onclick={() => scroll()} + onclick={() => jumpToBottom()} > @@ -279,6 +336,33 @@ {/each} + + +
+ {#if showJumpButton} + + {/if} +
@@ -287,6 +371,33 @@ {/each} + + +
+ {#if showJumpButton} + + {/if} +
{#if !collapsed} -
-
-
- {'Message'} -
-
-
-
- -
-
-
-
-
@@ -309,6 +287,29 @@
+ +
+
+
+ {'Initial Message'} +
+
+
+
+ +
+
+
+
+ {/if}