feat(chat): visual redesign — input, streaming indicator, scroll polish (#9232)

* feat(chat): visual redesign — input, streaming indicator, scroll polish

Visual refresh of the AI chat surface used in both the global right-side
panel (Cmd+L) and inline editor panels. No new features, no system-prompt
or tool changes, no sessions code.

Input redesign
- Default textarea to `rows={1}` and autosize as the user types.
- Drop the separate Send button row in favour of a single
  `<Button variant="subtle" iconOnly>` overlaid bottom-right of the
  textarea — `ArrowUp` when idle (disabled until text is typed),
  `Square` when loading (cancels via `aiChatManager.cancel()`).
- Padding `!pl-3 !pr-10 !py-2` keeps text clear of the floating button.
- Top spacing `mt-1` on the outer wrapper restores breathing room
  above the input (lost when the old @-button row was removed).
- Context chip row renders only when something is selected.
- `ContextTextarea` `min-height: 2.25rem` so the empty textarea
  collapses to a tight single line.

Streaming indicator
- Replace the old floating "Stop" button with a sticky-bottom badge
  showing three animated typing dots and a formatted wall-clock
  (`Xs`, `Xm Ys`, `Xh Ym`) — driven by `aiChatManager.loading`.
- CSS keyframes `chat-typing` with staggered animation-delay for the
  wave effect.

Scroll behaviour
- Replace `onwheel`-based stick-to-bottom detection with `onscroll`
  position check (8px threshold). Auto-scroll re-engages when the
  user scrolls back near the tail.
- Smooth scroll → `behavior: 'auto'` so token-append doesn't race
  the animation.
- New `enableAutomaticScroll` method on `AIChatManager`, complement to
  the existing `disableAutomaticScroll`.
- Floating "scroll to latest" arrow (`ArrowDown` design-system Button,
  `transition:fade`, `unifiedSize="xs"`, `iconOnly`) appears once the
  user scrolls >200px above the tail; click re-enables auto-scroll
  and jumps to bottom. Centered horizontally over the scroll viewport.

Message rendering
- Assistant markdown tuned: `prose-headings:font-medium`, h1 `text-sm`,
  h2+ `text-xs`, plus `prose-p:text-xs prose-li:text-xs
  prose-code:text-xs prose-pre:text-xs`. Stops AI replies blasting
  oversized titles.
- Fenced code blocks shrink to `!text-xs` on the `not-prose` wrapper
  so fenced code matches inline code at 12px.
- User-message wrapper switches to symmetric spacing (`mt-4 mb-6`)
  with a new `isLast` prop that adds `!mb-12` to the latest message
  — breathing room between the last bubble and the input without
  affecting siblings.

Layout / padding
- Wide-layout messages tightened to `px-7` (was `px-8`); input outer
  to `px-6`. The input box sits a touch left of the message text;
  textarea's own `!pl-3` brings the typed text back into alignment
  with the messages above.

Other
- `AIChatManager` class is now exported (was private). Allows callers
  to type a `getContext<AIChatManager>('aiChatManager')` provider
  override. No behaviour change for the global singleton.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(chat): restore @ picker, extract typing indicator and shared helpers

* feat(chat): cap non-wide chat at max-w-2xl, add side padding, drop input top border

* feat(chat): esc cancels active generation, tone down snapshot row

* fix(chat): only draw tool-content fade when content actually overflows

* style(chat): tighten non-wide side padding (px-4/px-3 -> px-3/px-2)

* fix(chat): inline ⌘K shows dots + stop button, swallow programmatic scroll events

* fix(chat): keep scroll-to-latest fresh during cooldown; ResizeObserver for tool-content fade

* fix(chat): contain wide content - propagate showFade, table scroll, bubble + inline code wrapping

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-05-20 09:10:25 +02:00
committed by GitHub
parent f066c3df1f
commit 31a046973a
15 changed files with 542 additions and 241 deletions
@@ -120,7 +120,6 @@
loadPastChat={(id) => {
aiChatManager.loadPastChat(id)
}}
cancel={aiChatManager.cancel}
askAi={aiChatManager.askAi}
{headerLeft}
hasDiff={aiChatManager.scriptEditorOptions &&
@@ -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<number | null>(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 @@
</script>
<div class="flex flex-col h-full">
<div
class="flex flex-row items-center justify-between gap-2 p-2 border-b border-gray-200 dark:border-gray-600"
>
<div class="flex flex-row items-center gap-2">
{@render headerLeft?.()}
<p class="text-sm font-semibold">Chat</p>
</div>
<div class="flex flex-row items-center gap-2">
<Popover>
{#snippet trigger()}
{#if !hideHeader}
<div
class="flex flex-row items-center justify-between gap-2 p-2 border-b border-gray-200 dark:border-gray-600"
>
<div class="flex flex-row items-center gap-2">
{@render headerLeft?.()}
<p class="text-sm font-semibold">Chat</p>
</div>
<div class="flex flex-row items-center gap-2">
<Popover>
{#snippet trigger()}
<Button
on:click={() => {}}
title="History"
@@ -132,10 +197,8 @@
color="light"
propagateEvent
/>
{/snippet}
{#snippet content({ close })}
{/snippet}
{#snippet content({ close })}
<div class="p-1 overflow-y-auto max-h-[300px]">
{#if pastChats.length === 0}
<div class="text-center text-primary text-xs">No history</div>
@@ -170,74 +233,89 @@
</div>
{/if}
</div>
{/snippet}
</Popover>
<Button
title="New chat"
on:click={() => {
saveAndClear()
}}
size="md"
btnClasses="!p-1"
startIcon={{ icon: Plus }}
iconOnly
variant="border"
color="light"
/>
{@render headerRight?.()}
{/snippet}
</Popover>
<Button
title="New chat"
on:click={() => {
saveAndClear()
}}
size="md"
btnClasses="!p-1"
startIcon={{ icon: Plus }}
iconOnly
variant="border"
color="light"
/>
{@render headerRight?.()}
</div>
</div>
</div>
{/if}
{#if messages.length === 0}
<span class="text-2xs text-gray-500 dark:text-gray-400 text-center px-2 my-2"
>You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the
script editor to modify selected lines.</span
>
{#if emptyHint}
{@render emptyHint()}
{:else}
<span class="text-2xs text-gray-500 dark:text-gray-400 text-center px-2 my-2"
>You can use {getModifierKey()}L to open or close this chat, and {getModifierKey()}K in the
script editor to modify selected lines.</span
>
{/if}
{/if}
{#if messages.length > 0}
<div
class="h-full overflow-y-scroll pt-2 pb-12"
bind:this={scrollEl}
onwheel={() => {
aiChatManager.disableAutomaticScroll()
}}
>
<div class="flex flex-col" bind:clientHeight={height}>
{#each messages as message, messageIndex (messageIndex)}
<AIChatMessage
{message}
{messageIndex}
{availableContext}
bind:selectedContext
bind:editingMessageIndex
/>
{/each}
{#if aiChatManager.loading && !aiChatManager.currentReply && !isLastMessageTool}
<div class="mb-6 py-1 px-2">
<Loader2 class="animate-spin" />
</div>
{/if}
<div class="flex-1 min-h-0 relative">
<div class="absolute inset-0 overflow-y-scroll pt-2" bind:this={scrollEl} onscroll={onScroll}>
<div
class={wideLayout
? 'w-full max-w-3xl mx-auto px-7 flex flex-col pb-2'
: 'w-full max-w-2xl mx-auto px-3 flex flex-col pb-2'}
bind:clientHeight={height}
>
{#each messages as message, messageIndex (messageIndex)}
<AIChatMessage
{message}
{messageIndex}
{availableContext}
bind:selectedContext
bind:editingMessageIndex
isLast={messageIndex === messages.length - 1}
/>
{/each}
{#if showTypingIndicator}
<div class="sticky bottom-2 z-10 mt-2 ml-2 self-start pointer-events-none">
<ChatTypingIndicator loading={aiChatManager.loading} />
</div>
{/if}
</div>
</div>
{#if showScrollToLatest}
<div
transition:fade={{ duration: 120 }}
class="absolute bottom-2 left-1/2 -translate-x-1/2 z-10"
>
<Button
variant="default"
unifiedSize="xs"
iconOnly
title="Scroll to latest"
aria-label="Scroll to latest message"
startIcon={{ icon: ArrowDown }}
on:click={() => {
aiChatManager.enableAutomaticScroll()
scrollDown()
}}
/>
</div>
{/if}
</div>
{/if}
<div class:border-t={messages.length > 0} class="relative">
{#if aiChatManager.loading}
<div class="absolute -top-10 w-full flex flex-row justify-center">
<Button
startIcon={{ icon: Square }}
size="xs"
variant="default"
btnClasses="bg-surface hover:bg-surface-selected"
on:click={() => {
cancel()
}}
>
Stop
</Button>
</div>
{:else if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
<div
class={wideLayout
? 'relative w-full max-w-3xl mx-auto px-6'
: 'relative w-full max-w-2xl mx-auto px-2'}
>
{#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
startIcon={{ icon: CheckIcon }}
@@ -263,7 +341,10 @@
</Button>
</div>
{/if}
<div class="px-2">
<div>
{#if inputPreface}
{@render inputPreface()}
{/if}
<AIChatInput
bind:this={aiChatInput}
bind:selectedContext
@@ -285,7 +366,9 @@
</div>
{:else}
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
<ChatMode />
{#if !hideModeSelector}
<ChatMode />
{/if}
{#if aiChatManager.mode === AIMode.APP}
<DatatableCreationPolicy />
{/if}
@@ -344,8 +427,8 @@
{#each suggestions as suggestion (suggestion)}
<Button
on:click={() => submitSuggestion(suggestion)}
variant="subtle"
size="xs2"
color="light"
btnClasses="whitespace-normal text-center font-normal"
>
{suggestion}
@@ -3,7 +3,7 @@
import AIChatInput from './AIChatInput.svelte'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import type { Selection } from 'monaco-editor'
import LoadingIcon from '$lib/components/apps/svelte-select/lib/LoadingIcon.svelte'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import { sendUserToast } from '$lib/toast'
import { onDestroy } from 'svelte'
import type { AIChatEditorHandler } from './monaco-adapter'
@@ -183,20 +183,19 @@
})
</script>
{#snippet bottomRightSnippet()}
{#if processing}
<LoadingIcon />
{:else if aiChatManager.pendingNewCode}
<span class="text-xs text-primary pr-1">
{getModifierKey()}↓ to apply
</span>
{:else}
<div></div>
{/if}
{#snippet pendingApplyHint()}
<span class="text-xs text-primary pr-1">
{getModifierKey()}↓ to apply
</span>
{/snippet}
{#if show}
<div bind:this={widgetElement} class="w-[300px] -mt-2">
<div bind:this={widgetElement} class="w-[300px] -mt-2 relative">
{#if processing}
<div class="absolute -top-5 left-2 pointer-events-none z-10">
<ChatTypingIndicator loading={processing} compact />
</div>
{/if}
<AIChatInput
bind:this={aiChatInput}
availableContext={aiChatManager.contextManager.getAvailableContext()}
@@ -231,9 +230,9 @@
}}
showContext={false}
className="-ml-2"
bottomRightSnippet={processing || aiChatManager.pendingNewCode
? bottomRightSnippet
: undefined}
bottomRightSnippet={aiChatManager.pendingNewCode ? pendingApplyHint : undefined}
loading={processing}
onCancel={() => aiChatManager.cancelInlineRequest('user pressed stop')}
disabled={processing}
/>
</div>
@@ -1,19 +1,23 @@
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import AppAvailableContextList from './AppAvailableContextList.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import ContextElementBadge from './ContextElementBadge.svelte'
import ContextTextarea from './ContextTextarea.svelte'
import autosize from '$lib/autosize'
import type { ContextElement } from './context'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import { AIMode } from './AIChatManager.svelte'
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { twMerge } from 'tailwind-merge'
import type { Snippet } from 'svelte'
import { tick, untrack, type Snippet } from 'svelte'
import Portal from '$lib/components/Portal.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { zIndexes } from '$lib/zIndexes'
import { tick, untrack } from 'svelte'
import { ArrowUp, Square } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
const aiChatManager = getAiChatManager()
interface Props {
availableContext: ContextElement[]
selectedContext: ContextElement[]
@@ -29,6 +33,13 @@
showContext?: boolean
bottomRightSnippet?: Snippet
onKeyDown?: (e: KeyboardEvent) => void
// When provided, overrides `aiChatManager.loading` for the send/stop
// button — useful for callers driving their own request lifecycle
// (e.g. the inline ⌘K widget runs requests outside the global
// `aiChatManager.loading` flag).
loading?: boolean
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
onCancel?: () => void
}
let {
@@ -45,9 +56,32 @@
onSendRequest = undefined,
showContext = true,
bottomRightSnippet,
onKeyDown = undefined
onKeyDown = undefined,
loading,
onCancel
}: Props = $props()
// GLOBAL-mode suggestion pool. We pick one at mount-time so each new
// session lands on a different prompt; the choice stays stable for
// the lifetime of this input so the placeholder doesn't shuffle as
// the user is reading it.
const GLOBAL_PLACEHOLDER_SUGGESTIONS = [
'Write a hello-world flow',
'Create a script that lists files in an S3 bucket',
'Build a CRUD app for a customer table',
'Schedule a daily cleanup of old runs',
'Wrap an existing script into a flow with retries',
'Add an HTTP trigger to an existing script',
'Generate a report from a SQL query and email it',
'Create a Postgres resource and a script that queries it',
'Refactor a script to add error handling',
'List my workspace flows and scripts'
]
const globalSuggestion =
GLOBAL_PLACEHOLDER_SUGGESTIONS[
Math.floor(Math.random() * GLOBAL_PLACEHOLDER_SUGGESTIONS.length)
]
// Generate mode-specific placeholder
const modePlaceholder = $derived.by(() => {
if (!isFirstMessage) {
@@ -70,7 +104,7 @@
case AIMode.API:
return 'Make API calls...'
case AIMode.GLOBAL:
return 'Work across workspace items...'
return globalSuggestion
case AIMode.ASK:
return 'Ask questions about Windmill...'
default:
@@ -89,12 +123,16 @@
let appTooltipElement = $state<HTMLDivElement | undefined>(undefined)
let appTooltipCurrentViewNumber = $state(0)
export function focusInput() {
if (
aiChatManager.mode === AIMode.SCRIPT ||
// Modes that show the rich textarea with @-context support (workspace
// scripts, workspace flows, code blocks, DBs, etc.).
const isContextEnabledMode = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL
) {
)
export function focusInput() {
if (isContextEnabledMode) {
contextTextareaComponent?.focus()
} else {
instructionsTextareaComponent?.focus()
@@ -393,96 +431,117 @@
})
</script>
<div use:clickOutside class="relative">
{#if aiChatManager.mode === AIMode.SCRIPT || aiChatManager.mode === AIMode.FLOW || aiChatManager.mode === AIMode.GLOBAL}
{#if showContext}
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 no-scrollbar">
<Popover>
{#snippet trigger()}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
>@</div
>
{/snippet}
{#snippet content({ close })}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void addContextToSelection(element)
close()
}}
/>
{/snippet}
</Popover>
{#each selectedContext as element (element.type + '-' + element.title)}
<ContextElementBadge
contextElement={element}
deletable
onDelete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/each}
</div>
{/if}
<ContextTextarea
bind:this={contextTextareaComponent}
bind:value={instructions}
{availableContext}
{selectedContext}
{isFirstMessage}
placeholder={modePlaceholder}
onAddContext={(contextElement) => void addContextToSelection(contextElement)}
onSendRequest={() => {
if (disabled) {
return
}
{#snippet sendStopButton()}
{@const isLoading = loading ?? aiChatManager.loading}
{@const sendDisabled = disabled || instructions.trim().length === 0}
<Button
variant="subtle"
unifiedSize="md"
iconOnly
title={isLoading ? 'Stop' : 'Send'}
startIcon={{ icon: isLoading ? Square : ArrowUp }}
disabled={!isLoading && sendDisabled}
on:click={() => {
if (isLoading) {
onCancel ? onCancel() : aiChatManager.cancel()
} else if (!sendDisabled) {
onSendRequest ? onSendRequest(instructions) : sendRequest()
}}
{disabled}
{onKeyDown}
/>
{:else if aiChatManager.mode === AIMode.APP}
{#if showContext}
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 no-scrollbar">
<Popover>
{#snippet trigger()}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
>@</div
>
{/snippet}
{#snippet content({ close })}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void addContextToSelection(element)
close()
}}
/>
{/snippet}
</Popover>
{#each selectedContext as element (element.type + '-' + element.title)}
<ContextElementBadge
contextElement={element}
deletable
onDelete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}
}}
/>
{/snippet}
{#snippet contextPickerRow()}
<div class="flex flex-row items-center gap-1 mt-1 overflow-scroll no-scrollbar">
<Popover>
{#snippet trigger()}
<div
class="border rounded-md px-1 py-0.5 font-normal text-primary text-xs hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if isContextEnabledMode}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void addContextToSelection(element)
close()
}}
/>
{/each}
</div>
{:else}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{#each selectedContext as element (element.type + '-' + element.title)}
<ContextElementBadge
contextElement={element}
deletable
onDelete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/each}
</div>
{/snippet}
<div
use:clickOutside
class="relative mt-1"
role="presentation"
onkeydown={(e) => {
if (e.key === 'Escape' && aiChatManager.loading) {
e.preventDefault()
aiChatManager.cancel()
}
}}
>
{#if isContextEnabledMode}
<div class="relative">
<ContextTextarea
bind:this={contextTextareaComponent}
bind:value={instructions}
{availableContext}
{selectedContext}
{isFirstMessage}
placeholder={modePlaceholder}
onAddContext={(contextElement) => void addContextToSelection(contextElement)}
onSendRequest={() => {
if (disabled) {
return
}
onSendRequest ? onSendRequest(instructions) : sendRequest()
}}
{disabled}
{onKeyDown}
/>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
</div>
{#if showContext}
{@render contextPickerRow()}
{/if}
{:else if aiChatManager.mode === AIMode.APP}
<div class={twMerge('relative w-full scroll-pb-2', className)}>
<textarea
bind:this={instructionsTextareaComponent}
@@ -510,12 +569,20 @@
sendRequest()
}
}}
rows={3}
rows={1}
placeholder={modePlaceholder}
class="resize-none"
class={twMerge('resize-none', CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
</div>
{#if showContext}
{@render contextPickerRow()}
{/if}
{#if showAppContextTooltip}
<Portal target="body">
<div
@@ -556,15 +623,20 @@
sendRequest()
}
}}
rows={3}
rows={1}
placeholder={modePlaceholder}
class="resize-none"
class={twMerge('resize-none', CHAT_INPUT_PADDING)}
{disabled}
></textarea>
{#if !bottomRightSnippet}
<div class="absolute bottom-1 right-1">
{@render sendStopButton()}
</div>
{/if}
</div>
{/if}
{#if bottomRightSnippet}
<div class="absolute bottom-2 right-2">
<div class="absolute bottom-1 right-1">
{@render bottomRightSnippet()}
</div>
{/if}
@@ -95,7 +95,7 @@ function isWorkspacePath(path: string | undefined): path is string {
return path?.startsWith('f/') === true || path?.startsWith('u/') === true
}
class AIChatManager {
export class AIChatManager {
contextManager = new ContextManager()
historyManager = new HistoryManager()
abortController: AbortController | undefined = undefined
@@ -1001,6 +1001,10 @@ class AIChatManager {
this.#automaticScroll = false
}
enableAutomaticScroll = () => {
this.#automaticScroll = true
}
generateStep = async (moduleId: string, lang: ScriptLang, instructions: string) => {
if (!this.flowAiChatHelpers) {
throw new Error('No flow helpers found')
@@ -3,19 +3,22 @@
import type { DisplayMessage, ToolDisplayMessage } from './shared'
import ContextElementBadge from './ContextElementBadge.svelte'
import AssistantMessage from './AssistantMessage.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { Button } from '$lib/components/common'
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
import AIChatInput from './AIChatInput.svelte'
import type { ContextElement } from './context'
import ToolExecutionDisplay from './ToolExecutionDisplay.svelte'
const aiChatManager = getAiChatManager()
interface Props {
availableContext: ContextElement[]
selectedContext: ContextElement[]
message: DisplayMessage
messageIndex: number
editingMessageIndex: number | null
isLast?: boolean
}
let {
@@ -23,7 +26,8 @@
messageIndex,
availableContext,
selectedContext = $bindable(),
editingMessageIndex = $bindable(null)
editingMessageIndex = $bindable(null),
isLast = false
}: Props = $props()
function editMessage() {
@@ -36,8 +40,9 @@
<div
class={twMerge(
message.role === 'user' && messageIndex > 0 && 'mt-6',
'mb-2',
'mb-2 min-w-0',
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
isLast && '!mb-12',
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
)}
role="button"
@@ -53,7 +58,7 @@
</div>
{/if}
{#if message.role === 'user' && editingMessageIndex === messageIndex}
<div class="px-2">
<div class="px-2 max-w-lg">
<AIChatInput
{availableContext}
bind:selectedContext
@@ -69,30 +74,26 @@
/>
</div>
{:else}
<div
class={twMerge(
'text-sm py-1 mx-2',
message.role === 'user' &&
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg relative group',
(message.role === 'assistant' || message.role === 'tool') && 'px-[1px]',
message.role === 'tool' && 'text-primary'
)}
>
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary')}>
{#if message.role === 'assistant'}
<AssistantMessage {message} />
<div class="px-[1px]"><AssistantMessage {message} /></div>
{:else if message.role === 'tool'}
<ToolExecutionDisplay message={message as ToolDisplayMessage} />
<div class="px-[1px]"><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div>
{:else}
<span class="whitespace-pre-wrap">{message.content}</span>
<div
class="text-xs px-3 py-2 w-fit max-w-[min(32rem,100%)] bg-surface-accent-selected text-accent rounded-lg relative group break-words"
>
<span class="whitespace-pre-wrap">{message.content}</span>
</div>
{/if}
</div>
{/if}
{#if message.role === 'user' && message.snapshot}
<div class="mx-2 text-sm text-primary flex flex-row items-center justify-between gap-2 mt-2">
<div class="mx-2 text-2xs text-tertiary flex flex-row items-center justify-between gap-2 mt-2">
Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot
<Button
size="xs2"
variant="default"
unifiedSize="xs"
variant="subtle"
on:click={() => {
if (message.snapshot) {
if (message.snapshot.type === 'flow') {
@@ -6,14 +6,19 @@
import LinkRenderer from './LinkRenderer.svelte'
interface Props {
message: DisplayMessage;
message: DisplayMessage
}
let { message }: Props = $props();
let { message }: Props = $props()
</script>
<div
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6"
class="prose prose-sm dark:prose-invert w-full max-w-full leading-snug space-y-2 prose-ul:!pl-6
prose-p:text-xs prose-li:text-xs prose-code:text-xs prose-pre:text-xs
prose-code:break-words prose-a:break-words
prose-headings:font-medium prose-headings:text-emphasis prose-headings:mt-3 prose-headings:mb-1
prose-h1:text-sm prose-h2:text-xs prose-h3:text-xs prose-h4:text-xs prose-h5:text-xs prose-h6:text-xs
prose-table:block prose-table:max-w-full prose-table:overflow-x-auto prose-table:text-xs"
>
<Markdown
md={message.content}
@@ -0,0 +1,78 @@
<script lang="ts">
let { loading, compact = false }: { loading: boolean; compact?: boolean } = $props()
// Wall-clock for the typing-dots indicator. Starts on the rising edge of
// `loading`, ticks once a second, frozen on the last value when loading
// ends so callers reading the dots briefly after still see a coherent number.
let loadingStartedAt = $state<number | undefined>(undefined)
let loadingElapsedMs = $state(0)
$effect(() => {
if (!loading) {
loadingStartedAt = undefined
return
}
loadingStartedAt = Date.now()
loadingElapsedMs = 0
const interval = setInterval(() => {
if (loadingStartedAt) loadingElapsedMs = Date.now() - loadingStartedAt
}, 1000)
return () => clearInterval(interval)
})
function formatElapsed(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000))
if (total < 60) return `${total}s`
const m = Math.floor(total / 60)
const s = total % 60
if (m < 60) return s === 0 ? `${m}m` : `${m}m ${s}s`
const h = Math.floor(m / 60)
const rm = m % 60
return rm === 0 ? `${h}h` : `${h}h ${rm}m`
}
</script>
<span
class={compact
? 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-surface/80 backdrop-blur'
: 'inline-flex items-center gap-2 px-2 py-1 rounded-md bg-surface/80 backdrop-blur'}
aria-label="AI is generating a response"
>
<span class={compact ? 'inline-flex items-end gap-0.5' : 'inline-flex items-end gap-1'}>
<span
class={(compact ? 'w-1 h-1' : 'w-1.5 h-1.5') + ' rounded-full bg-blue-500 chat-typing-dot'}
></span>
<span
class={(compact ? 'w-1 h-1' : 'w-1.5 h-1.5') +
' rounded-full bg-blue-500 chat-typing-dot chat-typing-dot-2'}
></span>
<span
class={(compact ? 'w-1 h-1' : 'w-1.5 h-1.5') +
' rounded-full bg-blue-500 chat-typing-dot chat-typing-dot-3'}
></span>
</span>
<span class={(compact ? 'text-[10px]' : 'text-2xs') + ' text-tertiary tabular-nums leading-none'}
>{formatElapsed(loadingElapsedMs)}</span
>
</span>
<style>
.chat-typing-dot {
animation: chat-typing 1.2s ease-in-out infinite;
}
.chat-typing-dot-2 {
animation-delay: 0.15s;
}
.chat-typing-dot-3 {
animation-delay: 0.3s;
}
@keyframes chat-typing {
0%,
60%,
100% {
opacity: 0.3;
}
30% {
opacity: 1;
}
}
</style>
@@ -6,6 +6,7 @@
import Portal from '$lib/components/Portal.svelte'
import { zIndexes } from '$lib/zIndexes'
import { twMerge } from 'tailwind-merge'
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
interface Props {
value: string
@@ -302,7 +303,8 @@
<div class="relative w-full scroll-pb-2 bg-surface">
<div
class={twMerge(
'textarea-input absolute top-0 left-0 pointer-events-none py-1 !px-2',
'textarea-input absolute top-0 left-0 pointer-events-none',
CHAT_INPUT_PADDING,
className
)}
>
@@ -315,7 +317,7 @@
onkeydown={handleKeyDown}
bind:value
use:autosize
rows={3}
rows={1}
oninput={handleInput}
onblur={() => {
setTimeout(() => {
@@ -329,6 +331,7 @@
{placeholder}
class={twMerge(
'textarea-input resize-none bg-transparent caret-black dark:caret-white overflow-clip',
CHAT_INPUT_PADDING,
className
)}
style={value.length > 0 ? 'color: transparent; -webkit-text-fill-color: transparent;' : ''}
@@ -379,6 +382,6 @@
white-space: pre-wrap;
word-break: break-words;
width: 100%;
min-height: 3rem;
min-height: 2.25rem;
}
</style>
@@ -74,6 +74,33 @@
console.error('Failed to copy:', err)
}
}
// Only draw the bottom fade when the content actually overflows and the
// user hasn't scrolled to the bottom. `showFade` is the parent's intent;
// `canScrollDown` is the live measurement on the inner scroll container.
let scrollEl: HTMLDivElement | undefined = $state()
let canScrollDown = $state(false)
function updateCanScrollDown() {
if (!scrollEl) {
canScrollDown = false
return
}
canScrollDown = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight > 1
}
// Mount-time + content-change measurement via ResizeObserver. The
// observer fires on initial observe (catches first paint) and on every
// size change of the scroll container or its content (catches streaming
// JSON growing past max-h-28). User scrolls fire `onscroll` directly.
$effect(() => {
if (!scrollEl) return
updateCanScrollDown()
const ro = new ResizeObserver(updateCanScrollDown)
ro.observe(scrollEl)
const inner = scrollEl.firstElementChild
if (inner) ro.observe(inner)
return () => ro.disconnect()
})
</script>
{#if showWhileLoading || (!loading && hasContent) || streaming}
@@ -114,12 +141,16 @@
<div
class="bg-surface-secondary border border-gray-200 dark:border-gray-700 rounded overflow-hidden relative"
>
<div class="p-3 overflow-x-auto max-h-28 overflow-y-auto">
<div
bind:this={scrollEl}
onscroll={updateCanScrollDown}
class="p-3 overflow-x-auto max-h-28 overflow-y-auto"
>
<pre class="text-2xs text-primary whitespace-pre-wrap"
>{formatJson($state.snapshot(content))}</pre
>
</div>
{#if showFade}
{#if showFade && canScrollDown}
<div
class="absolute bottom-0 left-0 right-0 h-16 pointer-events-none bg-gradient-to-t from-surface-secondary via-surface-secondary/70 via-surface-secondary/40 to-transparent"
></div>
@@ -134,6 +134,7 @@
content={message.logs}
loading={message.isLoading}
showWhileLoading={false}
showFade={message.showFade}
/>
{#if visibleActions.length > 0}
@@ -144,6 +145,7 @@
content={message.result}
error={message.error}
loading={message.isLoading}
showFade={message.showFade}
/>
{/if}
{/if}
@@ -0,0 +1,18 @@
import { getContext } from 'svelte'
import { aiChatManager as singletonAiChatManager, AIChatManager } from './AIChatManager.svelte'
const AI_CHAT_MANAGER_CONTEXT_KEY = 'aiChatManager'
// Resolve the AIChatManager instance for the current component subtree.
// Callers (e.g. the sessions pane) may set a scoped instance via
// `setContext('aiChatManager', ...)`; everywhere else falls back to the
// app-wide singleton so default usage stays unchanged.
export function getAiChatManager(): AIChatManager {
return getContext<AIChatManager>(AI_CHAT_MANAGER_CONTEXT_KEY) ?? singletonAiChatManager
}
// Padding profile shared by every chat input textarea / mirror layer.
// `!pr-10` reserves space for the absolute-positioned send/stop button at
// `bottom-1 right-1`; changing the button geometry means updating this
// constant in one place rather than four.
export const CHAT_INPUT_PADDING = '!pl-3 !pr-10 !py-2'
@@ -110,6 +110,7 @@ export function createApiTools(
requiresConfirmation: needsConfirmation,
confirmationMessage: `Run ${toolName}`,
showDetails: true,
showFade: true,
fn: async ({ args, toolId, toolCallbacks }) => {
const toolName = chatTool.function.name
const endpoint = endpointMap[toolName]
@@ -14,9 +14,12 @@
typescript,
yaml
} from 'svelte-highlight/languages'
import { aiChatManager, AIMode } from '../AIChatManager.svelte'
import { AIMode } from '../AIChatManager.svelte'
import { getAiChatManager } from '../aiChatManagerContext'
import { Check, Play } from 'lucide-svelte'
const aiChatManager = getAiChatManager()
const astNode = getAstNode()
function getSmartLang(lang: string) {
@@ -101,7 +104,7 @@
}
</script>
<div class="flex flex-col gap-0.5 rounded-lg relative not-prose">
<div class="flex flex-col gap-0.5 rounded-lg relative not-prose !text-xs">
<div
class="relative w-full border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"
>
@@ -277,6 +277,7 @@ const createScheduleTool: Tool<any> = {
requiresConfirmation: true,
confirmationMessage: 'Create schedule',
showDetails: true,
showFade: true,
validateBeforeConfirmation: ({ helpers }) => validateWorkspaceMutationTarget(helpers),
fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => {
try {
@@ -337,6 +338,7 @@ const createTriggerTool: Tool<any> = {
requiresConfirmation: true,
confirmationMessage: 'Create trigger',
showDetails: true,
showFade: true,
validateBeforeConfirmation: ({ helpers }) => validateWorkspaceMutationTarget(helpers),
fn: async ({ args, workspace, helpers, toolCallbacks, toolId }) => {
try {