feat: render flow chat mode through the AI session chat components

Flow chat mode drew its own bubbles from chat/ChatMessage and chat/ChatInput
while the AI session chat rendered the same kinds of message far better a
directory away. FlowChatViewHost maps a flow run's conversation onto
ChatViewHost, so both now render through AIChatDisplay.

Flow chat gains thinking blocks, workspace-path links, collapsible tool cards,
the typing indicator, scroll-to-latest, and queueing a message typed while the
run is in flight. FlowChatManager keeps its conversations, SSE and polling
untouched; the sidebar, the inputs modal and its localStorage are unchanged.

An assistant message can carry the flow step that produced it, rendered only
when a conversation holds more than one distinct step — with a single agent the
label repeats on every message and says nothing. It is counted over the
transcript rather than the flow's current steps, since a conversation outlives
edits to the flow. AssistantMessage also renders a windmill_s3_object result
through DisplayResult, as chat/ChatMessage did.

FlowChatInterface claims a min-height once there are messages: the editor's
Test-flow panel stacks the chat above the job result in an auto-height column,
where the transcript's absolute scroller would otherwise resolve to zero.

chat/ChatMessage and chat/ChatInput stay for AppChat and its customCss hooks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hw1yzWHyqqZmQrviUEhr5b
This commit is contained in:
Guilhem Lemouel
2026-08-31 15:55:53 +02:00
co-authored by Claude Opus 5
parent 4ac845dde6
commit c8350480df
6 changed files with 274 additions and 89 deletions
@@ -13,6 +13,7 @@
workspaceItemRegistry
} from './workspaceItems.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import DisplayResult from '$lib/components/DisplayResult.svelte'
interface Props {
message: DisplayMessage
@@ -58,6 +59,20 @@
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`
}
const stepName = $derived(message.role === 'assistant' ? message.stepName : undefined)
// A flow step can return a file rather than text; the raw JSON would be
// unreadable, so hand it to the result viewer instead of the markdown renderer.
const s3Object = $derived.by(() => {
if (!message.content.startsWith('{')) return undefined
try {
const parsed = JSON.parse(message.content)
return parsed?.type === 'windmill_s3_object' && parsed?.s3 ? parsed : undefined
} catch {
return undefined
}
})
const candidatePaths = $derived(extractCandidatePaths(message.content))
const rendererPlugin = {
renderer: {
@@ -111,7 +126,13 @@
</ChatCollapsibleCard>
{/if}
{#if message.content}
{#if stepName}
<div class="text-2xs text-tertiary font-medium mb-1">{stepName}</div>
{/if}
{#if s3Object}
<DisplayResult result={s3Object} workspaceId={$workspaceStore} noControls={true} />
{:else if message.content}
<div class="w-full space-y-2 {markdownProse.sm}">
<Markdown md={message.content} {plugins} />
</div>
@@ -629,6 +629,9 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
* would look like it is still streaming forever.
*/
streaming?: boolean
/** Flow step that produced this message, when the conversation is a flow run
* rather than a copilot turn. Rendered as a label above the content. */
stepName?: string
}
/**
@@ -17,6 +17,8 @@
path: string
hideSidebar?: boolean
inputSchema?: Record<string, any>
/** Wider centered column, for the full-page chat. */
wideLayout?: boolean
}
let {
@@ -25,7 +27,8 @@
useStreaming = false,
path,
hideSidebar = false,
inputSchema = undefined
inputSchema = undefined,
wideLayout = false
}: Props = $props()
const flowEditorContext = getContext<FlowEditorContext>('FlowEditorContext')
@@ -72,5 +75,11 @@
{#if !hideSidebar}
<FlowConversationsSidebar {manager} />
{/if}
<FlowChatInterface {manager} {deploymentInProgress} {additionalInputsSchema} {path} />
<FlowChatInterface
{manager}
{deploymentInProgress}
{additionalInputsSchema}
{path}
{wideLayout}
/>
</div>
@@ -1,9 +1,10 @@
<script lang="ts">
import { Alert, Button } from '$lib/components/common'
import { MessageCircle, Loader2, Settings2 } from 'lucide-svelte'
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
import ChatInput from '$lib/components/chat/ChatInput.svelte'
import { Button } from '$lib/components/common'
import { Loader2, MessageCircle, Settings2 } from 'lucide-svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
import { FlowChatViewHost } from './flowChatViewHost.svelte'
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
import Modal from '$lib/components/common/modal/Modal.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import { type DynamicInput } from '$lib/utils'
@@ -13,9 +14,16 @@
deploymentInProgress?: boolean
additionalInputsSchema?: Record<string, any>
path: string
wideLayout?: boolean
}
let { manager, deploymentInProgress = false, additionalInputsSchema, path }: Props = $props()
let {
manager,
deploymentInProgress = false,
additionalInputsSchema,
path,
wideLayout = false
}: Props = $props()
// Derive helperScript for dynamic inputs from schema
const dynamicInputHelperScript = $derived.by((): DynamicInput.HelperScript | undefined => {
@@ -63,19 +71,24 @@
showInputsModal = false
}
function handleSendMessage() {
const inputs = additionalInputsSchema
? (loadInputsFromStorage() ?? additionalInputsValues)
: undefined
manager.sendMessage(inputs)
}
function openInputsModal() {
const stored = loadInputsFromStorage()
if (stored) additionalInputsValues = stored
showInputsModal = true
}
const chatHost = new FlowChatViewHost(manager, () =>
additionalInputsSchema ? (loadInputsFromStorage() ?? additionalInputsValues) : undefined
)
setChatViewHost(chatHost)
// A message typed mid-run is held by the host; send it once the run settles.
$effect(() => {
if (!chatHost.loading && chatHost.queuedMessage) {
chatHost.flushQueuedMessage()
}
})
const hasMissingRequired = $derived.by(() => {
if (!additionalInputsSchema?.required?.length) return false
const values = additionalInputsValues ?? {}
@@ -101,83 +114,65 @@
</Modal>
{/if}
<div class="flex flex-col h-full flex-1 min-w-0">
<!-- Messages Container -->
<div
bind:this={manager.messagesContainer}
class="flex-1 min-h-0 overflow-y-auto p-4 bg-background"
onscroll={manager.handleScroll}
>
{#if deploymentInProgress}
<Alert type="warning" title="Deployment in progress" size="xs" />
{/if}
{#snippet emptyHint()}
<div class="flex-1 text-center text-tertiary flex items-center justify-center flex-col">
{#if manager.isLoadingMessages}
<div class="flex items-center justify-center h-full">
<Loader2 size={32} class="animate-spin" />
</div>
{:else if manager.messages.length === 0}
<div class="text-center text-tertiary flex items-center justify-center flex-col h-full">
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
<p class="text-lg font-medium">Start a conversation</p>
<p class="text-sm">Send a message to run the flow and see the results</p>
</div>
<Loader2 size={32} class="animate-spin" />
{:else}
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each manager.messages as message (message.id)}
<ChatMessage
role={message.message_type}
content={message.content}
loading={message.loading}
success={message.success}
stepName={message.step_name}
/>
{/each}
{#if manager.isWaitingForResponse}
<div class="flex items-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span class="text-sm">Processing...</span>
</div>
<MessageCircle size={48} class="mx-auto mb-4 opacity-50" />
<p class="text-lg font-medium">Start a conversation</p>
<p class="text-sm">Send a message to run the flow and see the results</p>
{/if}
</div>
{/snippet}
{#snippet inputPreface()}
{#if additionalInputsSchema}
<div class="flex items-center justify-end w-full mb-1">
<div class="relative">
<Button
unifiedSize="xs"
variant="default"
startIcon={{ icon: Settings2 }}
title="Inputs"
onClick={openInputsModal}
>
Inputs
</Button>
{#if hasMissingRequired}
<span class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full"></span>
{/if}
</div>
{/if}
</div>
<!-- Chat Input -->
<div class="flex flex-col items-center p-2 xl:max-w-7xl mx-auto w-full gap-2">
{#if additionalInputsSchema}
<div class="flex items-center justify-end w-full">
<div class="relative">
<Button
size="xs"
variant="default"
startIcon={{ icon: Settings2 }}
title="Inputs"
onClick={openInputsModal}
>
Inputs
</Button>
{#if hasMissingRequired}
<span class="absolute -top-1 -right-1 w-2 h-2 bg-yellow-500 rounded-full"></span>
{/if}
</div>
</div>
{/if}
<div class="w-full" class:opacity-50={deploymentInProgress}>
<ChatInput
bind:value={manager.inputMessage}
bind:bindTextarea={manager.inputElement}
disabled={manager.isLoading || deploymentInProgress}
onSend={handleSendMessage}
onKeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault()
handleSendMessage()
}
}}
showCancelButton={manager.isWaitingForResponse || manager.isLoading}
onCancel={() => manager.cancelCurrentJob()}
sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
/>
</div>
</div>
{/if}
{/snippet}
<!-- The transcript scroller fills its flex row, which needs a height to resolve
against. Not every host gives one (the editor's Test-flow panel stacks the
chat above the job result in an auto-height column), so once there are
messages to scroll, claim one. -->
<div
class="flex flex-col h-full flex-1 min-w-0"
class:min-h-96={chatHost.displayMessages.length > 0}
>
<AIChatDisplay
messages={chatHost.displayMessages}
bind:scrollElement={manager.messagesContainer}
onTranscriptScroll={manager.handleScroll}
pastChats={[]}
diffMode={false}
selectedContext={[]}
availableContext={[]}
hideHeader
hideModeSelector
{wideLayout}
{emptyHint}
{inputPreface}
placeholder="Send a message to run the flow"
disabled={deploymentInProgress}
disabledMessage="Deployment in progress"
loadPastChat={() => {}}
deletePastChat={() => {}}
saveAndClear={() => {}}
/>
</div>
@@ -0,0 +1,156 @@
import type {
ChatSendRequestOptions,
ChatViewHost
} from '$lib/components/copilot/chat/chatViewHost'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { ChatMessage, FlowChatManager } from './FlowChatManager.svelte'
import { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
function toDisplayMessage(
message: ChatMessage,
userIndex: number,
showStepNames: boolean
): DisplayMessage {
switch (message.message_type) {
case 'user':
return { role: 'user', index: userIndex, content: message.content }
case 'tool':
// Both producers of a tool row — the agent executor and the frontend's own
// stream parser — write the whole message as a one-line description of the
// call ("Used web_search tool"). There is no result to reveal, so the row
// is the label and nothing else. `toolName` stays unset: it drives the
// copilot's plan-card detection, which a flow step summary must not trip.
return {
role: 'tool',
tool_call_id: message.id,
content: message.content,
isLoading: message.loading
}
default:
return {
role: 'assistant',
content: message.content,
streaming: message.streaming,
stepName: showStepNames ? (message.step_name ?? undefined) : undefined
}
}
}
/**
* Renders a flow run's conversation through the AI session chat components. The
* turn itself is a flow job, so everything the copilot's own loop owns — context
* elements, attachments, autonomy, model choice — is absent here, and the chrome
* driving it hides itself (see ChatViewHost).
*/
export class FlowChatViewHost implements ChatViewHost {
#manager: FlowChatManager
#additionalInputs: () => Record<string, any> | undefined
constructor(
manager: FlowChatManager,
additionalInputs: () => Record<string, any> | undefined = () => undefined
) {
this.#manager = manager
this.#additionalInputs = additionalInputs
}
// The step name says which AI agent step wrote a message, so it only tells the
// reader anything once a conversation holds more than one. Counted over the
// transcript rather than over the flow's current steps: a conversation outlives
// edits to the flow, so it can carry labels from a shape the flow no longer has.
#showStepNames = $derived.by(
() => new Set(this.#manager.messages.map((m) => m.step_name).filter(Boolean)).size > 1
)
displayMessages = $derived.by(() => {
let userIndex = 0
const showStepNames = this.#showStepNames
return this.#manager.messages.map((message) =>
toDisplayMessage(message, message.message_type === 'user' ? userIndex++ : -1, showStepNames)
)
})
messages: readonly unknown[] = []
contextTokens = 0
loading = $derived.by(() => this.#manager.isLoading || this.#manager.isWaitingForResponse)
loadingLabel = undefined
compacting = false
currentReply = ''
currentReasoning = ''
currentReasoningActive = false
reasoningHiddenIndicatorLabel = undefined
#automaticScroll = $state(true)
get automaticScroll() {
return this.#automaticScroll
}
enableAutomaticScroll = () => {
this.#automaticScroll = true
}
disableAutomaticScroll = () => {
this.#automaticScroll = false
}
instructions = ''
// The flow run is the send: it is in flight for as long as the job is.
get sendInFlight() {
return this.#manager.isLoading
}
sendRequest = async (options: ChatSendRequestOptions = {}) => {
const text = options.instructions?.trim()
if (!text) return false
this.#manager.inputMessage = text
await this.#manager.sendMessage(this.#additionalInputs())
return true
}
cancel = () => {
void this.#manager.cancelCurrentJob()
}
setAiChatInput = () => {}
// A message typed while the flow is running waits here and goes out when the
// run finishes (see flushQueuedMessage).
queuedMessage = $state('')
queuedContext = undefined
queuedImages = []
queuedFiles = []
queueMessage = (text: string) => {
const trimmed = text.trim()
if (!trimmed) return
this.queuedMessage = this.queuedMessage ? `${this.queuedMessage}\n${trimmed}` : trimmed
}
dequeueMessage = () => {
this.queuedMessage = ''
}
/** Send whatever was typed during the run. Called once the run settles. */
flushQueuedMessage = () => {
const queued = this.queuedMessage
if (!queued) return
this.queuedMessage = ''
void this.sendRequest({ instructions: queued })
}
setComposerStaged = () => {}
clearComposerStaged = () => {}
attachmentBytesExcluding = () => 0
storedImages = () => undefined
retryRequest = () => {}
restartGeneration = () => {}
handleUserQuestionAnswer = () => false
handleToolConfirmation = () => {}
mode = undefined
isSessionChat = false
supportsModelSettings = false
supportsMessageEditing = false
tools = []
autonomyMode = AIAutonomyMode.DEFAULT
setAutonomyMode = () => {}
autoAcceptEditsActive = false
autoAcceptEditsAvailable = false
autoAcceptToolConfirmationsAvailable = false
planModeAvailable = false
attachedFiles = new AttachedFilesStore()
artifacts = new SessionArtifactsStore()
}
@@ -705,6 +705,7 @@
path={flow?.path ?? ''}
useStreaming={shouldUseStreaming}
inputSchema={flow?.schema}
wideLayout
/>
{:else}
{@const hasSchema =