mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 00:00:33 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user