* ui: split the markdown rendering setting per surface User content and thinking get their own toggle again, so turning off markdown for a message leaves reasoning blocks formatted. Both default to markdown. A stored renderContentAsRawText unfolds onto the user key and is dropped from the config. File mentions render as badges in the raw text path too, through a narrow pass over [name](file://path) that leaves everything else untouched. * ui: let the rich chat input scroll past its max height The contenteditable renderer caps its height with max-height but had no overflow rule, so a long buffer overflowed into the input area wrapper and got clipped by its overflow-hidden, leaving no way to reach the bottom of the message. The textarea renderer scrolls natively and was never affected. * ui: apply the new lint and format config * ui: move the render keys unfolding into the migration service Address review from @allozaur: the settings store no longer rewrites persisted config on load, the raw text toggle now unfolds onto the per-surface render keys in migration.service.ts, next to the other config migrations. The mention scanner flag and the directory path suffix become named constants.
164 lines
4.0 KiB
Svelte
164 lines
4.0 KiB
Svelte
<script lang="ts">
|
|
import { Lightbulb } from '@lucide/svelte';
|
|
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
|
|
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
|
import { AgenticSectionType } from '$lib/enums';
|
|
import { config } from '$lib/stores/settings.svelte';
|
|
import type { DatabaseMessageExtra } from '$lib/types';
|
|
import type { AgenticSection } from '$lib/utils';
|
|
|
|
interface Props {
|
|
section: AgenticSection;
|
|
open: boolean;
|
|
isStreaming: boolean;
|
|
hasReasoningError?: boolean;
|
|
attachments?: DatabaseMessageExtra[];
|
|
onToggle?: () => void;
|
|
}
|
|
|
|
let {
|
|
attachments,
|
|
hasReasoningError = false,
|
|
isStreaming,
|
|
onToggle,
|
|
open,
|
|
section
|
|
}: Props = $props();
|
|
|
|
const currentConfig = config();
|
|
|
|
const REASONING_HEADER = 'Reasoning';
|
|
const REASONING_HEADER_PENDING = 'Reasoning...';
|
|
const REASONING_SUBTITLE_ERROR = 'Error';
|
|
const REASONING_SUBTITLE_CANCELLED = 'Cancelled';
|
|
|
|
const isPending = $derived(section.type === AgenticSectionType.REASONING_PENDING);
|
|
const title = $derived(isPending && isStreaming ? REASONING_HEADER_PENDING : REASONING_HEADER);
|
|
const subtitle = $derived.by(() => {
|
|
if (isPending && !isStreaming) {
|
|
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
|
|
}
|
|
|
|
if (section.wasInterrupted) {
|
|
return hasReasoningError ? REASONING_SUBTITLE_ERROR : REASONING_SUBTITLE_CANCELLED;
|
|
}
|
|
|
|
return isStreaming ? '' : undefined;
|
|
});
|
|
const shimmerTitle = $derived(isPending && isStreaming);
|
|
|
|
let scrollEl: HTMLDivElement | undefined = $state();
|
|
|
|
const SCROLL_BOTTOM_THRESHOLD_PX = REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX;
|
|
|
|
let userScrolledUp = $state(false);
|
|
let lastScrollTop = 0;
|
|
let pendingFrame: number | null = null;
|
|
|
|
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(() => {
|
|
void section.content;
|
|
|
|
if (!scrollEl || !isPending || !isStreaming) return;
|
|
|
|
scrollToBottomOnFrame();
|
|
});
|
|
|
|
$effect(() => {
|
|
// Layout shifts that don't change section.content (markdown re-parse,
|
|
// syntax-highlight settle, image loads).
|
|
if (!scrollEl || !isPending || !isStreaming) return;
|
|
|
|
const observer = new MutationObserver(() => scrollToBottomOnFrame());
|
|
|
|
observer.observe(scrollEl, {
|
|
characterData: true,
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
|
|
return () => observer.disconnect();
|
|
});
|
|
|
|
$effect(() => {
|
|
// Pin to bottom at the start of each round.
|
|
if (!isPending) {
|
|
userScrolledUp = false;
|
|
lastScrollTop = 0;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<CollapsibleContentBlock
|
|
{open}
|
|
class="my-2"
|
|
icon={Lightbulb}
|
|
iconClass="h-3.5 w-3.5"
|
|
{title}
|
|
{subtitle}
|
|
{shimmerTitle}
|
|
{onToggle}
|
|
>
|
|
<div
|
|
bind:this={scrollEl}
|
|
class="reasoning-content"
|
|
class:is-streaming={isPending}
|
|
onscroll={handleScrollEvent}
|
|
>
|
|
{#if currentConfig.renderThinkingAsMarkdown}
|
|
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
|
|
{:else}
|
|
<div
|
|
class="text-[13px] leading-relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
|
>
|
|
{section.content}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</CollapsibleContentBlock>
|
|
|
|
<style>
|
|
.reasoning-content.is-streaming {
|
|
max-height: 28rem;
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
scrollbar-gutter: stable;
|
|
padding-right: 0.25rem;
|
|
}
|
|
</style>
|