From af0cfdd9d7be32f87e16faf360e245f71d018ff1 Mon Sep 17 00:00:00 2001 From: Jicheng Lu Date: Mon, 14 Sep 2026 12:14:22 -0500 Subject: [PATCH] Add copy affordances, collapsible long content, and follow-the-tail scrolling Chat and log panes: - Copy button on each content log entry; timestamp moves to its own row beneath the agent name and the button. - Copy button on fenced code blocks in agent messages. The button is emitted by a marked renderer override so it survives streaming re-renders, and the code is read back out of the DOM on click so the clipboard matches the screen and no message content passes through generated markup. Off by default elsewhere via a new `copyableCode` prop. - Both the message thread and the log panes now follow new content whenever they are already at the bottom, instead of only after the jump button was pressed. Scrolling back down resumes following on its own. Collapsible long content: - New shared CollapsibleText component: clips past ~10 measured lines, with a fade mask and click-anywhere or keyboard toggling. - Applied to the chat bubbles (held off while a reply streams), and it now backs the content log rows and the conversation page's dialog entries, replacing three separate hand-rolled implementations. Removes the dialog page's hardcoded-white '...' indicator, which was wrong in dark mode, and the double clamp it needed to work around Markdown's inner BFC. Class naming: - Rename the agent detail page's `ad-` prefix to `agd-`; ad blockers ship element-hiding rules that match a leading `ad-` and were hiding the page's layout. `cta-popup` renamed to `cta-overlay` as a precaution. Co-Authored-By: Claude Opus 5 --- src/lib/common/markdown/Markdown.svelte | 211 +++++++++++++++++- src/lib/common/shared/CollapsibleText.svelte | 156 +++++++++++++ src/lib/styles/app.scss | 2 + .../styles/components/_collapsible-text.scss | 86 +++++++ src/lib/styles/pages/_agent.scss | 31 +-- src/lib/styles/pages/_chat.scss | 133 ++++++----- src/lib/styles/pages/_conversation.scss | 54 ----- .../[conversationId]/chat-box.svelte | 98 ++++---- .../chat-util/chat-text-area.svelte | 4 +- .../persist-log/content-log-element.svelte | 107 ++++----- .../persist-log/persist-log.svelte | 79 +++++-- .../rich-content/rc-message.svelte | 2 +- src/routes/page/agent/[agentId]/+page.svelte | 26 +-- .../conv-dialog-element.svelte | 70 ++---- 14 files changed, 730 insertions(+), 329 deletions(-) create mode 100644 src/lib/common/shared/CollapsibleText.svelte create mode 100644 src/lib/styles/components/_collapsible-text.scss diff --git a/src/lib/common/markdown/Markdown.svelte b/src/lib/common/markdown/Markdown.svelte index 477be265..832cdf97 100644 --- a/src/lib/common/markdown/Markdown.svelte +++ b/src/lib/common/markdown/Markdown.svelte @@ -18,7 +18,15 @@ /** @type {boolean} */ rawText = false, /** @type {boolean} */ - scrollable = false + scrollable = false, + /** + * Puts a copy button on every fenced code block. Off by default: on a log + * row or a state dump the control would be chrome over content nobody + * copies, and it is the message surfaces where a snippet is there to be + * taken somewhere else. + * @type {boolean} + */ + copyableCode = false } = $props(); /** @@ -40,13 +48,54 @@ * paragraph that merely mentions a run link in passing gets the treatment too, which is * the right answer — it is still an offer of a live view, wherever it sits. */ - const liveLinkRenderer = new Renderer(); - const renderParagraph = liveLinkRenderer.paragraph.bind(liveLinkRenderer); - liveLinkRenderer.paragraph = (text) => { - if (!liveRunIdInText(text)) return renderParagraph(text); + /** + * Wraps a fenced code block so it can carry a copy button. + * + * Emitted as part of the markdown HTML rather than attached to the DOM + * afterwards, so it survives every re-render of a streaming message without + * anything having to re-scan for new blocks. Nothing from the message is + * interpolated into it — the code is read back out of the DOM when the button + * is clicked, which keeps the copied text identical to what is on screen and + * keeps message content out of generated markup. + * + * @param {string} codeHtml + */ + function withCopyButton(codeHtml) { + return '
' + + '' + + codeHtml + + '
'; + } - return `

${text}

`; - }; + /* Per instance rather than module-level, because the code renderer below + depends on `copyableCode`, which differs by call site. */ + const markdownRenderer = buildRenderer(); + + function buildRenderer() { + const renderer = new Renderer(); + + const renderParagraph = renderer.paragraph.bind(renderer); + renderer.paragraph = (text) => { + if (!liveRunIdInText(text)) return renderParagraph(text); + + return `

${text}

`; + }; + + if (copyableCode) { + const renderCode = renderer.code.bind(renderer); + /** + * @param {string} code + * @param {string | undefined} infostring + * @param {boolean} escaped + */ + renderer.code = (code, infostring, escaped) => withCopyButton(renderCode(code, infostring, escaped)); + } + + return renderer; + } const scrollbarId = `markdown-scrollbar-${uuidv4()}`; const options = { @@ -138,11 +187,84 @@ }; } + /** The button currently showing its "copied" state, if any. */ + /** @type {Element | null} */ + let copiedBtn = null; + /** @type {any} */ + let copyResetTimer = null; + + /** + * @param {Element} btn + * @param {boolean} done + */ + function setCopyState(btn, done) { + const icon = btn.querySelector('i'); + const label = btn.querySelector('.md-code-copy-label'); + btn.classList.toggle('md-code-copy-done', done); + if (icon) icon.className = done ? 'bx bx-check' : 'bx bx-copy'; + if (label) label.textContent = done ? 'Copied!' : 'Copy'; + btn.setAttribute('aria-label', done ? 'Code copied' : 'Copy code'); + } + + /** + * Copies a fenced code block, delegated for the same reason as + * `interceptLinks`: the buttons come from `{@html}`, so Svelte never sees + * those nodes and cannot bind to them. + * + * @param {HTMLElement} node + */ + function interceptCodeCopy(node) { + /** @param {MouseEvent} e */ + const onClick = (e) => { + const btn = /** @type {Element | null} */ (e.target)?.closest?.('.md-code-copy'); + if (!btn) return; + + // The surfaces this renders on put their own click on the block around + // it — collapse a message, jump to its log entry — and reaching for a + // snippet is not a request for either. + e.preventDefault(); + e.stopPropagation(); + + // Read from the DOM, so what lands on the clipboard is exactly what is + // on screen. The trailing newline is `marked`'s, not the author's. + const codeEl = btn.parentElement?.querySelector('pre code') || btn.parentElement?.querySelector('pre'); + const text = (codeEl?.textContent || '').replace(/\n$/, ''); + if (!text) return; + + navigator.clipboard?.writeText(text).then(() => { + if (copiedBtn && copiedBtn !== btn) { + setCopyState(copiedBtn, false); + } + clearTimeout(copyResetTimer); + copiedBtn = btn; + setCopyState(btn, true); + copyResetTimer = setTimeout(() => { + // A streaming re-render can have replaced the button by now. + if (copiedBtn?.isConnected) { + setCopyState(copiedBtn, false); + } + copiedBtn = null; + }, 800); + }).catch(() => { + // Clipboard refused — an insecure context, or permission denied. + // Leave the button alone rather than report a copy that never was. + }); + }; + + node.addEventListener('click', onClick); + return { + destroy() { + clearTimeout(copyResetTimer); + node.removeEventListener('click', onClick); + } + }; + } + let innerText = $derived.by(() => { const normalizedText = typeof text !== 'string' ? `${JSON.stringify(text)}` : text; const markedText = !rawText - ? replaceNewLine(marked(replaceMarkdown(normalizedText || ''), { renderer: liveLinkRenderer })?.toString()) - : marked(normalizedText || '', { breaks: true, renderer: liveLinkRenderer })?.toString(); + ? replaceNewLine(marked(replaceMarkdown(normalizedText || ''), { renderer: markdownRenderer })?.toString()) + : marked(normalizedText || '', { breaks: true, renderer: markdownRenderer })?.toString(); if (!!markedText && markedText.endsWith('
')) { const idx = markedText.lastIndexOf('
'); return markedText.substring(0, idx); @@ -157,6 +279,7 @@ class={`markdown-container markdown-lite ${containerClasses || 'text-white'}`} style={`${containerStyles}`} use:interceptLinks + use:interceptCodeCopy > {@html innerText} + +
+
+ {@render children?.()} +
+
+ + {#if isCollapsible} + + + {/if} + diff --git a/src/lib/styles/app.scss b/src/lib/styles/app.scss index 13c28868..06b6d752 100644 --- a/src/lib/styles/app.scss +++ b/src/lib/styles/app.scss @@ -9,6 +9,8 @@ * Loaded once from src/routes/+layout.svelte alongside the icon CSS. */ +@import "components/collapsible-text"; + @import "pages/auth"; @import "pages/chat"; @import "pages/conversation"; diff --git a/src/lib/styles/components/_collapsible-text.scss b/src/lib/styles/components/_collapsible-text.scss new file mode 100644 index 00000000..956b0d27 --- /dev/null +++ b/src/lib/styles/components/_collapsible-text.scss @@ -0,0 +1,86 @@ +/* ======================================================================== + * src/lib/common/shared/CollapsibleText.svelte + * ======================================================================== + * Shared by the chat bubbles, the persist-log content rows and the + * conversation page's dialog entries, so nothing here may assume a + * particular surface: no background, no padding, no colour of its own + * beyond the toggle. + */ + + +.ctxt { + display: flex; + flex-direction: column; + align-items: flex-start; + min-width: 0; + max-width: 100%; +} + + +.ctxt-end { + align-items: flex-end; +} + + +.ctxt-view { + max-width: 100%; +} + + +.ctxt-view-clickable { + cursor: pointer; +} + + +/* The mask is doing the real work. The two things it replaces each had a + surface-specific flaw: a hard cut sliced the chat bubble's rounded corners + flat, and the painted '...' it replaces on the conversation page was drawn + over a hard-coded white gradient, which was wrong the moment the page was + in dark mode. A mask paints nothing, so it is correct on every surface and + in both themes. */ +.ctxt-view-collapsed { + max-height: var(--ctxt-max-height, 16rem); + overflow: hidden; + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 40px), transparent 100%); + mask-image: linear-gradient(to bottom, #000 calc(100% - 40px), transparent 100%); +} + + +/* Deliberately quiet: it belongs to the content it sits under, not to any row + of actions nearby, so it reads as a hint rather than another button. */ + + +.ctxt-toggle { + display: inline-flex; + align-items: center; + gap: 3px; + margin-top: 2px; + padding: 0.1rem 0.4rem; + background: transparent; + border: 0; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + line-height: 1.6; + color: color-mix(in srgb, var(--color-primary) 80%, transparent); + cursor: pointer; + transition: color 0.15s ease, background-color 0.15s ease; + + + i { + font-size: 13px; + line-height: 1; + } + + + &:hover { + color: var(--color-primary); + background-color: color-mix(in srgb, var(--color-primary) 12%, transparent); + } + + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-primary) 55%, transparent); + outline-offset: 2px; + } +} diff --git a/src/lib/styles/pages/_agent.scss b/src/lib/styles/pages/_agent.scss index ccfbd4c3..e61ea071 100644 --- a/src/lib/styles/pages/_agent.scss +++ b/src/lib/styles/pages/_agent.scss @@ -336,10 +336,15 @@ $panel-radius: 0.5rem; /* ======================================================================== * src/routes/page/agent/[agentId]/+page.svelte - * ======================================================================== */ + * ======================================================================== + * Prefix is `agd-` (agent detail), NOT `ad-`: ad blockers ship element-hiding + * rules that match a leading `ad-` on a class, which hid this page's layout + * for anyone running one. Keep new classes here clear of `ad`, `ads`, + * `banner`, `sponsor` and `promo` for the same reason. + */ -.ad-page { +.agd-page { display: flex; flex-direction: column; } @@ -350,14 +355,14 @@ $panel-radius: 0.5rem; the cards' min-content is wider than 40% of the page. */ -.ad-grid { +.agd-grid { display: grid; grid-template-columns: 2fr 3fr; gap: 1.125rem; } -.ad-col { +.agd-col { display: flex; flex-direction: column; gap: 0.875rem; @@ -366,26 +371,26 @@ $panel-radius: 0.5rem; @media (max-width: 991.98px) { - .ad-grid { + .agd-grid { grid-template-columns: minmax(0, 1fr); } } /* ===== Section wrapper ===== - Each ad-section hosts a panel card. The card owns its own surface (see the + Each agd-section hosts a panel card. The card owns its own surface (see the panel-shell mixin); this is only the grid slot. No hover lift — the panels are flat and static, and a panel that moves reads as clickable when it is not. */ -.ad-section { +.agd-section { display: block; } @media (max-width: 423px) { - .ad-section { + .agd-section { height: fit-content; } } @@ -399,7 +404,7 @@ $panel-radius: 0.5rem; button's edge lines up with the panels above it. */ -.ad-action-bar { +.agd-action-bar { display: flex; flex-wrap: wrap; align-items: center; @@ -412,7 +417,7 @@ $panel-radius: 0.5rem; /* ===== Buttons ===== */ -.ad-btn { +.agd-btn { display: inline-flex; align-items: center; gap: 0.45rem; @@ -445,7 +450,7 @@ $panel-radius: 0.5rem; } -.ad-btn-primary { +.agd-btn-primary { background-color: var(--color-primary); color: rgb(255 255 255); @@ -456,7 +461,7 @@ $panel-radius: 0.5rem; } -.ad-btn-danger { +.agd-btn-danger { background-color: var(--color-danger); color: rgb(255 255 255); @@ -473,7 +478,7 @@ $panel-radius: 0.5rem; reads as a unified cluster. */ -.ad-btn-ghost { +.agd-btn-ghost { background-color: rgb(255 255 255); border-color: rgb(229 231 235); color: var(--color-primary); diff --git a/src/lib/styles/pages/_chat.scss b/src/lib/styles/pages/_chat.scss index 991f6e6e..1f2a1c60 100644 --- a/src/lib/styles/pages/_chat.scss +++ b/src/lib/styles/pages/_chat.scss @@ -2795,7 +2795,7 @@ /* ===== Popup container ===== */ -.cta-popup { +.cta-overlay { position: absolute; bottom: calc(100% + 6px); left: 0; @@ -2912,7 +2912,7 @@ /* ===== Dark mode ===== */ -.dark .cta-popup { +.dark .cta-overlay { background-color: rgb(31 41 55); border-color: rgb(55 65 81); color: rgb(229 231 235); @@ -3567,6 +3567,10 @@ .cle-meta { position: relative; z-index: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; font-size: 13px; color: color-mix(in srgb, white 68%, transparent); margin-bottom: 8px; @@ -3586,6 +3590,19 @@ } +/* First meta line: agent name on the left, copy button on the right. + The timestamp sits on its own line below. */ + + +.cle-meta-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; +} + + /* H4-equivalent heading inside meta row (replaces Bootstrap .h4) */ @@ -3626,13 +3643,69 @@ } +/* Per-log copy button in the meta row. Sits at low opacity until the log + element is hovered or the button itself is focused, so it never competes + with the log text. */ + + +.cle-copy-btn { + display: inline-flex; + align-items: center; + gap: 4px; + flex: 0 0 auto; + padding: 0.16rem 0.5rem; + font-size: 11px; + font-weight: 600; + line-height: 1; + color: color-mix(in srgb, white 72%, transparent); + background: color-mix(in srgb, black 18%, transparent); + border: 1px solid color-mix(in srgb, var(--color-secondary) 26%, transparent); + border-radius: 999px; + cursor: pointer; + opacity: 0.45; + transition: opacity 0.15s ease, color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; + + + i { + font-size: 13px; + } + + + &:hover { + color: rgb(255 255 255); + background-color: color-mix(in srgb, var(--color-primary) 20%, transparent); + border-color: color-mix(in srgb, var(--color-primary) 35%, transparent); + } + + + &:focus-visible { + opacity: 1; + outline: 2px solid color-mix(in srgb, var(--color-primary) 55%, transparent); + outline-offset: 2px; + } +} + + +.cle-element:hover .cle-copy-btn, +.cle-copy-btn:focus-visible, +.cle-copy-btn-done { + opacity: 1; +} + + +.cle-copy-btn-done { + color: color-mix(in srgb, var(--color-success, #22c55e) 70%, white); + border-color: color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent); + background: color-mix(in srgb, var(--color-success, #22c55e) 14%, transparent); +} + + /* Timestamp suffix (replaces Bootstrap .ms-2) */ .cle-meta-ts { display: inline-flex; align-items: center; - margin-left: 0.5rem; padding: 0.12rem 0.45rem; font-size: 11px; color: color-mix(in srgb, white 58%, transparent); @@ -3914,58 +3987,6 @@ } -/* Collapsed text clamp (replaces .log-collapse nested under .log-content). - Note: -webkit-line-clamp with display:-webkit-box forces the browser to - resolve overflow-x to 'auto' even if we only set overflow-y:hidden, - which surfaces a native horizontal scrollbar whenever the clamped - content has long lines. Setting overflow:hidden (both axes) clips - cleanly without ever showing a scrollbar in collapsed state. */ - - -.cle-collapse { - overflow: hidden; - height: fit-content; - max-height: 200px; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 10; -} - - -/* 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) */ - - -.cle-toggle-btn { - background: color-mix(in srgb, var(--color-primary) 11%, transparent); - border: 1px solid color-mix(in srgb, var(--color-primary) 16%, transparent); - outline: none; - box-shadow: none; - color: color-mix(in srgb, var(--color-primary) 75%, white); - font-size: 12px; - font-weight: 700; - padding: 0.18rem 0.55rem; - margin-top: 8px; - border-radius: 999px; - cursor: pointer; - transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; - - - &:hover { - color: rgb(255 255 255); - background-color: color-mix(in srgb, var(--color-primary) 20%, transparent); - border-color: color-mix(in srgb, var(--color-primary) 35%, transparent); - transform: translateY(-1px); - text-decoration: none; - } -} - - /* MessageId footer (replaces inline style="margin-top: 10px;") */ @@ -5410,7 +5431,7 @@ } - .cta-popup { + .cta-overlay { bottom: calc(100% + 8px); max-height: 14rem; font-size: 0.75rem; diff --git a/src/lib/styles/pages/_conversation.scss b/src/lib/styles/pages/_conversation.scss index 6a0590a8..783595ac 100644 --- a/src/lib/styles/pages/_conversation.scss +++ b/src/lib/styles/pages/_conversation.scss @@ -261,60 +261,6 @@ } -/* ======================================================================== - * src/routes/page/conversation/[conversationId]/conv-dialog-element.svelte - * ======================================================================== - * Replaces the legacy `.text-collapse` rule from - * src/lib/scss/custom/pages/_conversation.scss. The dialog text is - * collapsed to the first 10 lines by default; clicking the More button - * removes this class so the content can flow to its natural height. - * - * Two clamping mechanisms are layered here for reliability: - * 1. `-webkit-line-clamp: 10` with `display: -webkit-box` gives the - * nicer "line truncation with ellipsis" effect on simple text. - * 2. `max-height: 10lh` is the hard fallback. The inner `` - * wraps its rendered HTML in a `.markdown-container` with - * `overflow-x: auto`, which establishes its own block formatting - * context — that BFC defeats line-clamp on the parent because text - * flow is sealed inside the child. The max-height cap clips - * reliably regardless of inner BFCs and is what actually keeps long - * code blocks / tables collapsed. A `::after` pseudo-element below - * paints a '...' in the bottom-right corner so the truncation is - * still signaled visually when line-clamp's native ellipsis is - * suppressed by the inner BFC. - */ -.text-collapse { - position: relative; - overflow: hidden; - max-height: 10lh; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 10; - line-clamp: 10; -} - -/* Ellipsis fallback for collapsed content whose inner BFC defeats - * -webkit-line-clamp (notably the Markdown container's overflow-x: auto - * around code blocks / tables). The native line-clamp ellipsis already - * handles plain text; this pseudo-element guarantees a '...' indicator - * is rendered even when line-clamp is sealed inside the child BFC. - * - * The gradient fades from transparent to the conversation page - * background (white) so the '...' sits cleanly over the last visible - * line without obscuring readable text. */ -.text-collapse::after { - content: '...'; - position: absolute; - right: 0; - bottom: 0; - padding: 0 0.25rem 0 1.5rem; - background: linear-gradient(to right, rgba(255, 255, 255, 0), rgb(255 255 255) 50%); - color: inherit; - font-weight: inherit; - pointer-events: none; -} - - /* ======================================================================== * src/routes/page/conversation/[conversationId]/conv-dialogs.svelte * ======================================================================== */ diff --git a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte index 6edf71cb..3b40e80c 100644 --- a/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte +++ b/src/routes/chat/[agentId]/[conversationId]/chat-box.svelte @@ -66,6 +66,7 @@ import RichContent from './rich-content/rich-content.svelte'; import RcMessage from "./rich-content/rc-message.svelte"; import RcDisclaimer from './rich-content/rc-disclaimer.svelte'; + import CollapsibleText from '$lib/common/shared/CollapsibleText.svelte'; import RcEmbedding from './rich-content/rc-embedding.svelte'; import MessageFileGallery from '$lib/common/files/MessageFileGallery.svelte'; import ChatUtil from './chat-util/chat-util.svelte'; @@ -165,15 +166,16 @@ 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); + /** A bubble's own top + bottom padding, which sits inside its measured height. */ + const BUBBLE_PADDING_PX = 20; /* - * 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. + * Whether the thread is following the tail. Incoming socket messages and stream + * chunks move the viewport only while it is already at the bottom, so reading + * back through history is never interrupted; scrolling back down resumes the + * follow, as does the jump button (which works while a reply is still streaming, + * the point of it being clickable in that state). */ - let followStream = $state(false); + let isPinnedToBottom = $state(true); /** @type {import('$conversationTypes').ConversationModel} */ let conversation = $state(/** @type {any} */ (undefined)); @@ -239,7 +241,6 @@ let isListening = $state(false); let isLite = $state(false); let isFrame = $state(false); - let autoScrollLog = $state(false); let loadChatUtils = $state(false); let disableSpeech = $state(false); let isLoading = $state(false); @@ -424,8 +425,11 @@ 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; + // A jump into history is a deliberate move away from the tail. The scroll + // listener would work this out on its own once the smooth scroll starts, + // but a message landing in the same frame would read the stale value and + // drag the thread straight back down. + isPinnedToBottom = false; } activeIndexId = messageId; directToLog(messageId); @@ -509,7 +513,8 @@ /** * 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. + * the "jump to latest" button takes over; scrolling back down to the bottom + * puts the thread on the tail again. */ function trackBottomProximity() { const scrollbar = scrollbars[0]; @@ -519,9 +524,6 @@ const update = () => { const distanceFromBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight; isPinnedToBottom = distanceFromBottom <= BOTTOM_THRESHOLD_PX; - if (!isPinnedToBottom) { - followStream = false; - } updateActiveIndex(viewport); }; update(); @@ -752,9 +754,6 @@ * 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; @@ -964,12 +963,11 @@ resetProgress(); } - autoScrollLog = true; dialogs.push({ ...message, is_chat_message: true }); - refresh(!followStream); + refresh(); text = ""; } @@ -1000,7 +998,7 @@ isStreaming = false; latestStateLog = message.states; - refresh(!followStream); + refresh(); if (isFrame) { window.parent.postMessage(message, "*"); @@ -1024,7 +1022,7 @@ }); } - refresh(!followStream); + refresh(); if (isFrame) { window.parent.postMessage(message, "*"); @@ -1047,7 +1045,7 @@ } }); } - refresh(!followStream); + refresh(); } @@ -1070,7 +1068,7 @@ } dialogs[dialogs.length - 1].text += message.text; refreshDialogs(); - if (followStream) autoScrollToBottom(); + autoScrollToBottom(); }, 0); } } else { @@ -1106,7 +1104,7 @@ for (const tt of thinkingText) { dialogs[dialogs.length - 1].thought.thinking_text += tt; refreshDialogs(); - if (followStream) autoScrollToBottom(); + autoScrollToBottom(); await delay(10); } } @@ -1114,7 +1112,7 @@ for (const char of item.text) { dialogs[dialogs.length - 1].text += char; refreshDialogs(); - if (followStream) autoScrollToBottom(); + autoScrollToBottom(); await delay(10); } } catch (err) { @@ -1127,7 +1125,7 @@ /** @param {import('$conversationTypes').ChatResponseModel} message */ function afterReceiveLlmStreamMessage(message) { isStreaming = false; - refresh(!followStream); + refresh(); } function stopStreaming() { @@ -2646,23 +2644,31 @@ {:else} - +

{utcToLocal(message.created_at, 'h:mm:ss A')} @@ -2789,7 +2795,16 @@ {:else} - + {@const isLive = message?.message_id === lastBotMsg?.message_id + && message?.uuid === lastBotMsg?.uuid + && (isStreaming || isHandlingQueue || isThinking)} + + + + {/if} @@ -98,7 +98,7 @@ {/if} {#if loadUtils} -

+
{#if children} {@render children()} {/if} 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 84d3785d..4529ffcf 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 @@ -1,5 +1,6 @@
-
+
{#if data?.agent_id?.length > 0} @@ -109,28 +92,34 @@ {/if} - {`${utcToLocal(data?.created_at, 'hh:mm:ss.SSS A, MMM DD YYYY')} `} +
+ {`${utcToLocal(data?.created_at, 'hh:mm:ss.SSS A, MMM DD YYYY')} `}
- - -
-
+
+ + -
- - {#if isCollapsible} - - {/if} +
{#if data.message_id && data.source === ContentLogSource.UserInput} 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 931a22ef..ccecda8b 100644 --- a/src/routes/chat/[agentId]/[conversationId]/persist-log/persist-log.svelte +++ b/src/routes/chat/[agentId]/[conversationId]/persist-log/persist-log.svelte @@ -1,5 +1,5 @@ -
- {#if dialog?.rich_content?.message?.rich_type === RichType.ProgramCode - && dialog?.rich_content?.message?.language === 'javascript'} - - {:else} - - {/if} -
- -{#if isOverflowing} - -{/if} - + +
+ {#if dialog?.rich_content?.message?.rich_type === RichType.ProgramCode + && dialog?.rich_content?.message?.language === 'javascript'} + + {:else} + + {/if} +
+