diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 007675d0bf..07ccd1efb1 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -120,7 +120,6 @@ loadPastChat={(id) => { aiChatManager.loadPastChat(id) }} - cancel={aiChatManager.cancel} askAi={aiChatManager.askAi} {headerLeft} hasDiff={aiChatManager.scriptEditorOptions && diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index 9081283d09..3e57542dbf 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -2,17 +2,17 @@ import AIChatMessage from './AIChatMessage.svelte' import { type Snippet } from 'svelte' import { + ArrowDown, CheckIcon, HistoryIcon, - Loader2, MousePointer2, Plus, - Square, TextSelect, X, XIcon } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' + import { fade } from 'svelte/transition' import Popover from '$lib/components/meltComponents/Popover.svelte' import { type DisplayMessage } from './shared' import type { ContextElement } from './context' @@ -21,11 +21,15 @@ import ChatMode from './ChatMode.svelte' import DatatableCreationPolicy from './DatatableCreationPolicy.svelte' import Markdown from 'svelte-exmarkdown' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' import { getModifierKey } from '$lib/utils' import type { SelectedContext } from './app/core' + const aiChatManager = getAiChatManager() + let { messages, pastChats, @@ -36,13 +40,17 @@ loadPastChat, deletePastChat, saveAndClear, - cancel, askAi = () => {}, // todo: remove default, headerLeft, headerRight, disabled = false, disabledMessage = '', - suggestions = [] + suggestions = [], + hideHeader = false, + hideModeSelector = false, + wideLayout = false, + emptyHint, + inputPreface }: { messages: DisplayMessage[] pastChats: { id: string; title: string }[] @@ -53,31 +61,90 @@ loadPastChat: (id: string) => void deletePastChat: (id: string) => void saveAndClear: () => void - cancel: () => void askAi?: (instructions: string, options?: { withCode?: boolean; withDiff?: boolean }) => void headerLeft?: Snippet headerRight?: Snippet disabled?: boolean disabledMessage?: string suggestions?: string[] + hideHeader?: boolean + hideModeSelector?: boolean + // Center the messages + input columns inside a max-w-3xl px-8 + // inner container. The session pane uses this for breathing + // room; the right-hand global chat panel is narrow enough that + // the inner padding eats too much horizontal space, so it's + // off there. + wideLayout?: boolean + emptyHint?: Snippet + inputPreface?: Snippet } = $props() let aiChatInput: AIChatInput | undefined = $state() let editingMessageIndex = $state(null) let scrollEl: HTMLDivElement | undefined = $state() - async function scrollDown() { - scrollEl?.scrollTo({ - top: scrollEl.scrollHeight, - behavior: 'smooth' - }) + // Programmatic-scroll guard. `scrollDown()` triggers an async `scroll` + // event; if a token-append between the scrollTo and the dispatch makes + // scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and + // disengage auto-scroll mid-stream. A short cooldown after our own + // scroll swallows that spurious event without affecting genuine user + // scrolls (wheel/touch/keyboard are reaction-time orders of magnitude + // slower than the cooldown). + const PROGRAMMATIC_SCROLL_COOLDOWN_MS = 120 + let programmaticScrollAt: number | undefined + // Instant scroll — smooth would animate every token append, racing with + // the next scrollDown and confusing the onscroll bottom-detection below. + function scrollDown() { + if (!scrollEl) return + programmaticScrollAt = Date.now() + scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' }) } let height = $state(0) $effect(() => { - aiChatManager.automaticScroll && height && scrollDown() + if (aiChatManager.automaticScroll && height) { + scrollDown() + } + // Recompute the scroll-to-latest visibility on every content-height + // change. `onScroll` only fires for actual scroll events, so without + // this the arrow can go stale when content grows past the threshold + // while auto-scroll is disabled (user scrolled up mid-stream). + if (scrollEl && height) { + const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight + showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX + } }) + // Pixel distance from the bottom under which we treat the user as + // "stuck to the bottom" and re-enable automatic scroll. 8px allows for + // sub-pixel rounding from scrollTo + the occasional overscroll bounce. + const STICK_TO_BOTTOM_PX = 8 + // Show the "scroll to latest" arrow only once the user has scrolled + // meaningfully away from the tail — a couple of message-heights up. Avoids + // flicker when the auto-scroll lags by a few px during streaming. + const SCROLL_TO_LATEST_THRESHOLD_PX = 200 + let showScrollToLatest = $state(false) + function onScroll() { + if (!scrollEl) return + const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight + // Always refresh the arrow visibility — even during the cooldown, + // because clicking the arrow itself triggers a programmatic scroll + // whose only event would otherwise be swallowed, leaving the arrow + // stuck visible after we already reached the bottom. + showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX + if ( + programmaticScrollAt !== undefined && + Date.now() - programmaticScrollAt < PROGRAMMATIC_SCROLL_COOLDOWN_MS + ) { + return + } + if (distance <= STICK_TO_BOTTOM_PX) { + aiChatManager.enableAutomaticScroll() + } else { + aiChatManager.disableAutomaticScroll() + } + } + function submitSuggestion(suggestion: string) { aiChatManager.sendRequest({ instructions: suggestion }) } @@ -96,9 +163,7 @@ } }) - const isLastMessageTool = $derived( - messages.length > 0 && messages[messages.length - 1].role === 'tool' - ) + const showTypingIndicator = $derived(aiChatManager.loading) // Get app context for display when in APP mode const appContext = $derived.by((): SelectedContext | undefined => { @@ -110,17 +175,17 @@
-
-
- {@render headerLeft?.()} -

Chat

-
-
- - {#snippet trigger()} - + {#if !hideHeader} +
+
+ {@render headerLeft?.()} +

Chat

+
+
+ + {#snippet trigger()}
- - {/snippet} - -
-
+ {/if} {#if messages.length === 0} - You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the - script editor to modify selected lines. + {#if emptyHint} + {@render emptyHint()} + {:else} + You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the + script editor to modify selected lines. + {/if} {/if} {#if messages.length > 0} -
{ - aiChatManager.disableAutomaticScroll() - }} - > -
- {#each messages as message, messageIndex (messageIndex)} - - {/each} - {#if aiChatManager.loading && !aiChatManager.currentReply && !isLastMessageTool} -
- -
- {/if} +
+
+
+ {#each messages as message, messageIndex (messageIndex)} + + {/each} + {#if showTypingIndicator} +
+ +
+ {/if} +
+ {#if showScrollToLatest} +
+
+ {/if}
{/if} -
0} class="relative"> - {#if aiChatManager.loading} -
- -
- {:else if aiChatManager.flowAiChatHelpers?.hasPendingChanges()} +
+ {#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
{/if} -
+
+ {#if inputPreface} + {@render inputPreface()} + {/if} {:else}
- + {#if !hideModeSelector} + + {/if} {#if aiChatManager.mode === AIMode.APP} {/if} @@ -344,8 +427,8 @@ {#each suggestions as suggestion (suggestion)}