feat: flow chat job-backed detail, smooth streaming and answer chrome (#11186)

* feat: flow chat job-backed detail, smooth streaming and answer chrome

* chore: document setFlowPath, drop unused chatIdentity, tighten comments

* fix: read a nested agent tool's call from its own job

* fix: show only the model's arguments on a tool card read from its job

* feat: add attachments to the chat sdk message type

* refactor: read a flow chat message's files from the message, not its run

* fix: keep every job-backed answer when reads finish out of order

* refactor: build flow chat tool cards from the conversation row alone

* feat: keep the model picker's current choice when a flow chat retry replays its run

* fix: export ChatAttachment from the chat sdk entry point

* fix: drop a conversation list for the flow path before setFlowPath

* fix: return the current listing, not old-path rows, after setFlowPath

* fix: treat a listing stale on kind or path as one stale listing

* revert: drop setFlowPath and rebuild the chat when the flow path changes

* fix: refuse a flow chat retry once any turn ran while it read the run

* docs: say where a display message's createdAt comes from

* style: drop the rule between New chat and the conversation list
This commit is contained in:
Guilhem
2026-09-18 11:31:00 +02:00
committed by GitHub
parent c4c9677982
commit e2a91ca2b1
19 changed files with 893 additions and 214 deletions
@@ -471,7 +471,9 @@
return jobId ?? ''
}}
conversationKind="test"
frame="boxed"
path={$pathStore}
identity={$initialPathStore || fakeInitialPath}
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
/>
@@ -558,7 +560,13 @@
</div>
{/if}
{/if}
<div class="pt-4 flex flex-col border-t relative">
<!-- The rule divides the inputs form from its results. Chat mode has no form: the
chat is its own panel, and a second line right under it reads as a stray edge. -->
<div
class="pt-4 flex flex-col relative {flowStore.val.value?.chat_input_enabled
? ''
: 'border-t'}"
>
{#if flowHasChanged()}
<div class="pb-2">
<div
@@ -0,0 +1,66 @@
<script lang="ts">
/**
* A soft edge on a scroller, so content scrolling out of view fades instead of being
* cut against whatever borders it.
*
* Rendered as an overlay in the scroller's positioned ancestor rather than inside the
* scroller: `sticky` would resolve against the scroller's padding box and leave the
* first few pixels unfaded. It shows only when there is something hidden in that
* direction, so a transcript that fits shows no edge at all.
*/
import { twMerge } from 'tailwind-merge'
interface Props {
/** The scrolling element this masks. */
scroller: HTMLElement | undefined
edge?: 'top' | 'bottom'
/** Tailwind colour stop to fade from — the surface the scroller sits on. */
from?: string
/** Tailwind height of the fade band. */
height?: string
class?: string
}
let {
scroller,
edge = 'top',
from = 'from-surface',
height = 'h-4',
class: className = ''
}: Props = $props()
let hidden = $state(true)
$effect(() => {
const el = scroller
if (!el) return
const update = () => {
// A pixel of slack: fractional scroll offsets otherwise leave the bottom edge
// showing on a scroller that is already at its end.
hidden =
edge === 'top' ? el.scrollTop <= 1 : el.scrollTop + el.clientHeight >= el.scrollHeight - 1
}
update()
el.addEventListener('scroll', update, { passive: true })
// Content arriving or the pane resizing changes what is hidden without a scroll.
const observer = new ResizeObserver(update)
observer.observe(el)
if (el.firstElementChild) observer.observe(el.firstElementChild)
return () => {
el.removeEventListener('scroll', update)
observer.disconnect()
}
})
</script>
<div
class={twMerge(
'pointer-events-none absolute inset-x-0 transition-opacity duration-150',
edge === 'top' ? 'top-0 bg-gradient-to-b' : 'bottom-0 bg-gradient-to-t',
from,
'to-transparent',
height,
hidden ? 'opacity-0' : 'opacity-100',
className
)}
></div>
@@ -35,6 +35,7 @@
import ChatQuickActions from './ChatQuickActions.svelte'
import ContextUsageIndicator from './ContextUsageIndicator.svelte'
import AIChatModelSettings from './AIChatModelSettings.svelte'
import ScrollFade from '$lib/components/ScrollFade.svelte'
import AssistantSettingsModal from './AssistantSettingsModal.svelte'
import { SkillsMenu } from './skills/skillsMenu.svelte'
import { McpMenu } from '$lib/components/mcp/mcpMenu.svelte'
@@ -591,6 +592,15 @@
// The typing-dots indicator implies the AI is busy, which is misleading while
// the loop is parked on the user; surface a text pill instead so users know to
// act on the tool above.
// A step name hangs its icon in the column's left padding (see AssistantMessage), so a
// transcript carrying one widens the padding, on both sides to keep the column centred.
const agentGutter = $derived(messages.some((m) => m.role === 'assistant' && m.stepName))
const columnClass = $derived(
wideLayout
? `w-full max-w-3xl mx-auto ${agentGutter ? 'px-8' : 'px-7'}`
: `w-full max-w-2xl mx-auto ${agentGutter ? 'px-8' : 'px-3'}`
)
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
// Gated on `loading` because a card restored from history still looks parked:
@@ -823,12 +833,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={scrollElement}
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}
>
<div class="{columnClass} flex flex-col pb-2" bind:clientHeight={height}>
{#each messages as message, messageIndex (messageIndex)}
<AIChatMessage
{message}
@@ -867,6 +872,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
<!-- Sits below the scroll-to-latest button, which carries z-10. -->
<ScrollFade scroller={scrollElement} />
{#if showScrollToLatest}
<div
transition:fade={{ duration: 120 }}
@@ -892,11 +899,9 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{/if}
<div
class={wideLayout
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
>
<!-- Same horizontal padding as the transcript above: the composer's edges line up with
the messages rather than sitting closer to the panel edge. -->
<div class="relative {columnClass} pb-2">
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
@@ -95,7 +95,7 @@ import { copilotInfo } from '$lib/aiStore'
import { copilotWorkspaceRequested, loadCopilot } from '$lib/components/copilot/loadCopilot'
import { askTools, prepareAskSystemMessage, prepareAskUserMessage } from './ask/core'
import { readDocsPageTool, searchDocsTool } from './docs/core'
import { TypewriterReveal } from './typewriterReveal'
import { prefersInstantReveal, TypewriterReveal } from './typewriterReveal'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
import {
createAppBackendRunnableContextElement,
@@ -155,11 +155,6 @@ import { appendAttachedFilesRoster } from './files/fileTools'
import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from './planMode'
import { PlanModeController, type PlanModeHost } from './planModeController.svelte'
// SSR and users who prefer reduced motion get no typewriter pacing.
function prefersInstantReveal(): boolean {
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
}
// Compaction of the stored history: once the projected request size
// (contextTokens — the provider's report when current, a fresh chars/4
// estimate otherwise — plus the new user message) reaches the trigger ratio of
@@ -3959,6 +3954,9 @@ export class AIChatManager implements ChatViewHost {
{
role: 'assistant',
content: this.currentReply,
// Stamped as it lands. A chat restored from history predates this and
// simply shows no time rather than a made-up one.
createdAt: new Date().toISOString(),
...(this.currentReasoning
? { reasoning: this.currentReasoning, reasoningDurationMs }
: {}),
@@ -138,7 +138,9 @@
{:else}
<div class={twMerge('text-sm py-1 px-2', message.role === 'tool' && 'text-primary py-0')}>
{#if message.role === 'assistant'}
<div class="px-[1px]"><AssistantMessage {message} workspace={messageWorkspace} /></div>
<div class="px-[1px] group/answer"
><AssistantMessage {message} workspace={messageWorkspace} /></div
>
{:else if message.role === 'tool'}
<div class="px-[1px]"
><ToolExecutionDisplay message={message as ToolDisplayMessage} /></div
@@ -13,6 +13,10 @@
} from './workspaceItems.svelte'
import { markdownProse } from '$lib/components/markdownProse'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { Bot, ExternalLink } from 'lucide-svelte'
import CopyButton from '$lib/components/common/button/CopyButton.svelte'
import { base } from '$lib/base'
import { displayDate } from '$lib/utils'
interface Props {
message: DisplayMessage
@@ -23,6 +27,21 @@
let { message, workspace }: Props = $props()
// The run this answer came out of. Only a flow chat has one — a copilot turn runs in
// the browser — so the job link is absent rather than empty elsewhere.
const jobId = $derived(message.role === 'assistant' ? message.jobId : undefined)
const createdAt = $derived(message.role === 'assistant' ? message.createdAt : undefined)
const runHref = $derived(jobId ? `${base}/run/${jobId}?workspace=${workspace}` : undefined)
// Today's answers show the time alone; the day earns its place only on a conversation
// read back later. Resolved at render, so a chat left open across midnight keeps
// yesterday's format until it is reopened.
const timestamp = $derived.by(() => {
if (!createdAt) return undefined
const at = new Date(createdAt)
const today = new Date().toDateString() === at.toDateString()
return displayDate(at, false, !today)
})
const reasoning = $derived(
message.role === 'assistant' ? message.reasoning?.trim() || undefined : undefined
)
@@ -113,9 +132,16 @@
})
</script>
<!-- An agent step's answer is headed by the agent's own icon, hung in the margin so the
answer itself stays on the same left edge as the reader's messages. The icon sits in
the padding the message column already carries. -->
{#if stepName}
<div class="text-2xs text-tertiary font-medium mb-1 truncate" title="Answered by {stepName}">
{stepName}
<div
class="flex items-center gap-2 -ml-6 mb-1 text-2xs text-tertiary"
title="Answered by {stepName}"
>
<Bot size={16} class="shrink-0" />
<span class="font-mono truncate">{stepName}</span>
</div>
{/if}
@@ -140,3 +166,28 @@
<Markdown md={message.content} {plugins} />
</div>
{/if}
{#if message.content}
<!-- Kept in flow while invisible, so revealing it on hover does not nudge the message
below. A thinking-only row has no answer to copy, and the next row links its run. -->
<div
class="flex items-center gap-2 text-2xs text-tertiary opacity-0 transition-opacity duration-150 group-hover/answer:opacity-100 focus-within:opacity-100"
>
<CopyButton value={message.content} title="Copy answer" class="-ml-1" />
{#if timestamp}
<span>{timestamp}</span>
{/if}
{#if runHref}
<a
href={runHref}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{jobId?.slice(0, 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/if}
</div>
{/if}
@@ -9,8 +9,10 @@
CircleMinus,
FileText,
PanelRight,
Lock
Lock,
ExternalLink
} from 'lucide-svelte'
import { base } from '$lib/base'
import {
EXIT_PLAN_MODE_TOOL,
isPlanCardTool,
@@ -249,6 +251,19 @@
{/if}
{/snippet}
{#snippet jobLink()}
<a
href="{base}/run/{message.jobId}?workspace={chatHost.operatingWorkspace}"
target="_blank"
rel="noopener noreferrer"
class="shrink-0 inline-flex items-center gap-1 font-main text-2xs text-tertiary hover:text-primary hover:underline"
title="Open this run"
>
<span>job <span class="font-mono">{message.jobId?.slice(0, 8)}</span></span>
<ExternalLink size={11} class="shrink-0" />
</a>
{/snippet}
<!-- Which system a call reaches is the first thing to know about it, so an MCP call
is marked before its label. Awaited rather than drawn immediately: the MCP logo
appearing first and being replaced would flicker on every row. -->
@@ -275,7 +290,7 @@
headerClass={message.needsConfirmation ? 'opacity-80' : ''}
labelClass={showPreviewChip ? 'truncate' : ''}
contentClass="space-y-3"
headerRight={showPreviewChip ? previewChip : undefined}
headerRight={showPreviewChip ? previewChip : message.jobId ? jobLink : undefined}
headerLeft={mcpServer?.workspace ? serverMark : undefined}
>
<!-- Image a tool produced (e.g. take_screenshot) — shown inline, not gated on expand. -->
@@ -634,6 +634,8 @@ export type ToolDisplayMessage = {
* workspace rides along: a chat is readable from any workspace, and the same path names
* a different server in each. */
mcpServer?: { workspace: string; path: string }
/** The run behind the call. Flow chats only: the card links to it. */
jobId?: string
showFade?: boolean
actions?: ToolDisplayAction[]
userQuestion?: UserQuestionDisplay
@@ -677,7 +679,9 @@ export type AssistantDisplayMessage = BaseDisplayMessage & {
/** The run behind this answer. Flow chats only: a copilot turn happens in the
* browser and has no job. */
jobId?: string
/** When the message was stored, as the server reports it. */
/** When the answer arrived: the server's time for a flow chat's stored row, the browser's
* for a copilot answer, which is stamped as it lands. Absent on a copilot chat restored
* from history, which predates the stamp. */
createdAt?: string
}
@@ -9,6 +9,13 @@
// state is the `onReveal` callback — so the pacing is unit-testable with an
// injected clock and scheduler.
import { BROWSER } from 'esm-env'
/** SSR and readers who prefer reduced motion get no pacing: text lands as it arrives. */
export function prefersInstantReveal(): boolean {
return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false)
}
type Schedule = (cb: () => void) => unknown
type Cancel = (handle: unknown) => void
@@ -857,6 +857,7 @@
<FlowChat
onRunFlow={runFlowWithMessage}
path={$pathStore}
identity={$initialPathStore || fakeInitialPath}
conversationKind="test"
inputSchema={flowStore.val.schema}
flowModules={flowStore.val.value?.modules}
@@ -7,6 +7,7 @@
import { getContext } from 'svelte'
import type { FlowEditorContext } from '../types'
import type { FlowModule } from '$lib/gen'
import { FRAME_CLASS, type ChatFrame } from './flowChatProps'
interface Props {
/**
@@ -20,7 +21,15 @@
additionalInputs?: Record<string, any>
) => Promise<string | undefined>
deploymentInProgress?: boolean
/** The flow the chat runs and lists conversations for. Must be the path a run records,
* or a conversation is stored under one path and looked for under another. */
path: string
/**
* What the chat's stored inputs are filed under, when that is not the path. An unsaved
* flow's path changes as its author types, so the editor passes something that holds
* still for the flow it is editing.
*/
identity?: string
hideSidebar?: boolean
inputSchema?: Record<string, any>
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
@@ -29,6 +38,7 @@
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
wideLayout?: boolean
frame?: ChatFrame
/**
* What this surface's own runs are: the editor runs previews and lists its test
* chats, the flow page runs the deployed flow and lists only its users' chats.
@@ -42,11 +52,13 @@
onRunFlow,
deploymentInProgress = false,
path,
identity = undefined,
hideSidebar = false,
inputSchema = undefined,
flowModules = undefined,
description = undefined,
wideLayout = false,
frame = 'top',
conversationKind = 'deployed'
}: Props = $props()
@@ -103,7 +115,7 @@
})
</script>
<div class="flex border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden flex-1">
<div class="flex overflow-hidden flex-1 {FRAME_CLASS[frame]}">
{#if chat && chatState}
{#if !hideSidebar}
<FlowConversationsSidebar
@@ -114,20 +126,26 @@
canFilterKind={conversationKind !== 'deployed'}
/>
{/if}
<!-- The interface's host subscribes to the chat it was given, so a replaced chat
(another flow or workspace) mounts a fresh interface rather than a stale host. -->
{#key chat}
<FlowChatInterface
{chat}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{workspace}
{description}
{wideLayout}
{conversationKind}
/>
{/key}
<!-- pb-3 on the chat alone, not on the row: the transcript and composer stop short of
the panel edge the way the session chat does, while the sidebar and the border
dividing it from the chat still reach the bottom. -->
<div class="flex flex-1 min-w-0 min-h-0 pb-3">
<!-- The interface's host subscribes to the chat it was given, so a replaced chat
(another flow or workspace) mounts a fresh interface rather than a stale host. -->
{#key chat}
<FlowChatInterface
{chat}
{deploymentInProgress}
{additionalInputsSchema}
{flowModules}
{path}
{identity}
{workspace}
{description}
{wideLayout}
{conversationKind}
/>
{/key}
</div>
{/if}
</div>
@@ -10,6 +10,7 @@
import { emptyString, type DynamicInput } from '$lib/utils'
import { onDestroy, tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
import { chatFlowKey } from './flowChatProps'
import type { FlowModule } from '$lib/gen'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import {
@@ -35,6 +36,8 @@
* and the attachments input. */
flowModules?: FlowModule[]
path: string
/** What the stored inputs are filed under when the path is not steady (see FlowChat). */
identity?: string
workspace?: string
/** The flow's description, shown under the empty transcript's prompt. */
description?: string
@@ -49,6 +52,7 @@
additionalInputsSchema,
flowModules,
path,
identity = undefined,
workspace = undefined,
description = undefined,
wideLayout = false,
@@ -122,7 +126,7 @@
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
function getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}${path}`
return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}`
}
function loadInputsFromStorage(): Record<string, any> | null {
@@ -181,7 +185,10 @@
? undefined
: 'This workspace has no object storage, so files cannot be attached.',
workspace: () => workspace,
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason
sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
// The model controls only: a retry changes model when the reader did, but replays
// the run's own attachments rather than whatever the composer holds now.
inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
}
)
setChatViewHost(chatHost)
@@ -185,7 +185,7 @@
: 'w-[44px]'}"
>
<!-- Header -->
<div class="flex-shrink-0 border-b">
<div class="flex-shrink-0">
<div class="flex flex-col gap-2 p-1">
<Button
unifiedSize="md"
@@ -0,0 +1,19 @@
/** What separates the chat from what sits above it. `boxed` is its own panel, corners
* clipped so the sidebar's edge follows them; `top` a dividing line under an enclosing
* header; `none` for a surface where the chat is the whole pane. */
export type ChatFrame = 'boxed' | 'top' | 'none'
export const FRAME_CLASS: Record<ChatFrame, string> = {
boxed: 'border rounded-md',
top: 'border-t',
none: ''
}
/**
* Which flow a chat is for, as something that holds still: `identity` where a surface has
* one, since `path` follows the path field as its author types. `||`, not `??`: an empty
* identity is no identity.
*/
export function chatFlowKey(props: { path: string; identity?: string }): string {
return props.identity || props.path
}
@@ -11,7 +11,14 @@ import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/ar
import type { AttachedBlob } from '$lib/components/copilot/chat/blobUtils'
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
import type { AttachedTextFile } from '$lib/components/copilot/chat/textFileUtils'
import {
prefersInstantReveal,
TypewriterReveal,
type TypewriterRevealOptions
} from '$lib/components/copilot/chat/typewriterReveal'
import { JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { attachmentLanes } from './messageAttachments'
import type { AttachmentsTarget } from './agentAttachmentInput'
export type FlowChatViewHostOptions = {
@@ -23,12 +30,17 @@ export type FlowChatViewHostOptions = {
/** Why attaching is off despite the flow taking attachments no object storage, say.
* Undefined while the workspace has not answered: an explanation must not be a guess. */
attachmentsUnavailable?: () => string | undefined
/** The workspace the transcript's paths resolve against. */
/** The workspace the transcript's paths resolve against, and a retry reads its run from. */
workspace?: () => string | undefined
/** Whether sending is refused right now (a deployment in progress, say). The composer
* is disabled on the same condition; this covers the sends the composer does not
* make itself: a queued message going out, a retry. */
sendDisabled?: () => boolean
/** Flow inputs edited by a control beside the composer, such as the model button. A retry
* takes their current value rather than the failed turn's. */
inputsShownInComposer?: () => string[]
/** Injectables for tests: the clock and scheduler behind the typewriter pacing. */
revealOptions?: Pick<TypewriterRevealOptions, 'instant' | 'now' | 'schedule' | 'cancel'>
}
function isBusy(status: ChatState['status']): boolean {
@@ -79,6 +91,27 @@ function lastTurnFailed(messages: readonly ChatMessage[]): boolean {
return false
}
/**
* The step name says which AI agent step wrote a message, so it only tells the reader
* anything once the conversation holds more than one. Counted over the transcript rather
* than over the flow's steps: a conversation outlives edits to the flow, so it can carry
* labels from a shape the flow no longer has.
*/
export function showsStepNames(messages: readonly ChatMessage[]): boolean {
return new Set(messages.map((m) => m.stepName).filter(Boolean)).size > 1
}
/** How much of a streaming message is on screen, per lane, in characters. */
export type Revealed = { content: number; reasoning: number }
/** What a display row can only learn beyond the message itself. Every lookup is optional. */
export type DisplayLookups = {
/** The workspace an attachment's download link points into. */
workspace?: string
/** How much of a pending assistant row the pacing has put on screen. */
revealed?: (message: ChatMessage) => Revealed | undefined
}
/**
* What a failed tool call returned, as the card's error: the card shows the error in place of
* the result. A job failure is stored as `{ message, name, stack }`; an MCP failure as plain text.
@@ -104,9 +137,11 @@ const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/
export function toDisplayMessages(
messages: readonly ChatMessage[],
busy = false,
stopped: ReadonlySet<string> = new Set()
stopped: ReadonlySet<string> = new Set(),
lookups: DisplayLookups = {}
): DisplayMessage[] {
let userIndex = 0
const stepNames = showsStepNames(messages)
let latestUser = -1
for (let i = messages.length - 1; i >= 0 && latestUser < 0; i--) {
if (messages[i].role === 'user') latestUser = i
@@ -119,17 +154,23 @@ export function toDisplayMessages(
!(busy && i === latestUser) &&
!stopped.has(message.id) &&
!(message.serverId && stopped.has(message.serverId))
const { images, contextElements } = attachmentLanes(lookups.workspace, message.attachments)
return [
{
role: 'user',
index,
content: message.content,
// Drives the shared Retry button.
error: (settled && turnFailed(messages, i)) || undefined
error: (settled && turnFailed(messages, i)) || undefined,
images: images.length > 0 ? images : undefined,
contextElements: contextElements.length > 0 ? contextElements : undefined
}
]
}
case 'tool': {
// The model's call and what the tool sent back, as the stream carried them or the
// worker stored them on the row. A row stored without them shows its name and job.
const toolName = message.tool?.name
const parameters = parseToolPayload(message.tool?.arguments)
const result = parseToolPayload(message.tool?.result)
const failed = message.success === false
@@ -148,17 +189,17 @@ export function toDisplayMessages(
tool_call_id: message.id,
// The card's header is the row's text, which the server only words once the
// tool has returned; until then the row says what is running.
content:
message.content || unfinished || (message.tool ? `Running ${message.tool.name}` : ''),
content: message.content || unfinished || (toolName ? `Running ${toolName}` : ''),
// Withheld for the copilot's two plan-mode names: `toolName` is what makes
// ToolExecutionDisplay render a plan card, and an agent tool that happened to
// share one would silently become one.
toolName: isPlanCardTool(message.tool?.name) ? undefined : message.tool?.name,
toolName: isPlanCardTool(toolName) ? undefined : toolName,
parameters,
result,
showDetails: parameters !== undefined || result !== undefined,
error: failed ? (toolErrorText(result) ?? message.content) : unfinished,
isLoading: message.pending && message.tool?.status === 'running'
isLoading: message.pending && message.tool?.status === 'running',
jobId: message.jobId
}
// The tool card has no thinking section: the thinking that led to the call reads
// as a card of its own, just before it.
@@ -168,7 +209,7 @@ export function toDisplayMessages(
role: 'assistant',
content: '',
reasoning: message.reasoning,
stepName: message.stepName,
stepName: stepNames ? message.stepName : undefined,
jobId: message.jobId,
createdAt: message.createdAt
},
@@ -176,24 +217,37 @@ export function toDisplayMessages(
]
: [call]
}
default:
default: {
// The pacing's prefix while the message streams; the whole text once it has
// settled, or the turn was stopped.
const revealed = message.pending ? lookups.revealed?.(message) : undefined
return [
{
role: 'assistant',
content: message.content,
content: revealed ? message.content.slice(0, revealed.content) : message.content,
// Only the message a turn is still writing: a finalized reasoning-only
// message must not look in progress.
streaming: message.pending || undefined,
reasoning: message.reasoning,
stepName: message.stepName,
reasoning: revealed
? message.reasoning?.slice(0, revealed.reasoning)
: message.reasoning,
stepName: stepNames ? message.stepName : undefined,
jobId: message.jobId,
createdAt: message.createdAt
}
]
}
}
})
}
/** The two paced lanes of one streaming message, and how much of each has been fed in. */
type RevealLanes = {
content: TypewriterReveal
reasoning: TypewriterReveal
fed: Revealed
}
/**
* Renders a flow run's conversation, as the `windmill-chat` SDK keeps it, through the
* copilot's chat components. The turn is a flow job rather than an LLM call this host
@@ -224,6 +278,7 @@ export class FlowChatViewHost implements ChatViewHost {
this.#disposed = true
this.#queue = emptyQueue()
this.#unsubscribe()
for (const id of Object.keys(this.#reveals)) this.#dropReveal(id)
}
/** The latest `ChatState`, for what the interface reads beyond the seam (paging, loading). */
@@ -234,6 +289,7 @@ export class FlowChatViewHost implements ChatViewHost {
#onState(state: ChatState) {
const previous = this.#state
this.#state = state
this.#paceReveals(state.messages)
const landed = state.messages.filter(
(m) => m.serverId && this.#stoppedTurns.has(m.id) && !this.#stoppedTurns.has(m.serverId)
)
@@ -243,13 +299,12 @@ export class FlowChatViewHost implements ChatViewHost {
if (previous.conversationId !== state.conversationId) {
// A conversation opens at its end, whatever the reader was doing in the last one.
this.#automaticScroll = true
// A conversation reopened later comes back from the server under other message ids.
this.#sentAttachments.clear()
// The queue was typed into the conversation that just went away; a message sent
// after the switch would ride out of the wrong one, so it goes back to the composer.
this.dequeueMessage()
return
}
if (!isBusy(previous.status) && isBusy(state.status)) this.#turnsStarted++
if (isBusy(previous.status) && !isBusy(state.status)) {
// The turn settled. What was typed during it goes out once the turn is released,
// not now: the chat publishes `idle` from inside its own `sendMessage`, which still
@@ -263,9 +318,70 @@ export class FlowChatViewHost implements ChatViewHost {
}
}
// Smooth streaming. The chat appends each delta to the pending assistant message as it
// arrives, in the coarse bursts the provider sends; what is shown is a prefix that a
// typewriter advances per lane, so the bursts read as continuous typing. Plain fields
// for the pacers, reactive counts for what they have put on screen.
#reveals: Record<string, RevealLanes> = {}
#revealed = $state<Record<string, Revealed>>({})
#paceReveals(messages: readonly ChatMessage[]) {
const pending = new Set<string>()
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
if (message.role !== 'assistant' || !message.pending) continue
pending.add(message.id)
const lanes = (this.#reveals[message.id] ??= this.#newLanes(message.id))
const content = message.content.slice(lanes.fed.content)
const reasoning = (message.reasoning ?? '').slice(lanes.fed.reasoning)
lanes.fed = { content: message.content.length, reasoning: (message.reasoning ?? '').length }
lanes.content.push(content)
lanes.reasoning.push(reasoning)
// A row the turn has moved past — a tool card now follows it — is shown whole:
// what the pacing still holds belongs above that card, not trickling in under it.
if (i < messages.length - 1) {
lanes.content.flush()
lanes.reasoning.flush()
}
}
for (const id of Object.keys(this.#reveals)) {
if (!pending.has(id)) this.#dropReveal(id)
}
}
#newLanes(id: string): RevealLanes {
const options = this.#options.revealOptions ?? {}
const instant = options.instant ?? prefersInstantReveal()
const lane = (kind: keyof Revealed) =>
new TypewriterReveal({
...options,
instant,
onReveal: (chunk) => {
const current = this.#revealed[id] ?? { content: 0, reasoning: 0 }
this.#revealed[id] = { ...current, [kind]: current[kind] + chunk.length }
}
})
this.#revealed[id] = { content: 0, reasoning: 0 }
return {
content: lane('content'),
reasoning: lane('reasoning'),
fed: { content: 0, reasoning: 0 }
}
}
#dropReveal(id: string) {
this.#reveals[id]?.content.reset()
this.#reveals[id]?.reasoning.reset()
delete this.#reveals[id]
delete this.#revealed[id]
}
// Transcript
displayMessages = $derived.by(() =>
toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns)
toDisplayMessages(this.#state.messages, isBusy(this.#state.status), this.#stoppedTurns, {
workspace: this.#options.workspace?.(),
revealed: (message) => this.#revealed[message.id]
})
)
/** The user message of each turn stopped in this view, by message id and, once the chat has
* read its row, row id: a reopened conversation or an older page rebuilds messages from rows,
@@ -305,7 +421,15 @@ export class FlowChatViewHost implements ChatViewHost {
instructions = ''
// The user message lands in the transcript before `sendMessage` awaits anything.
sendInFlight = false
sendRequest = async (options: ChatSendRequestOptions = {}): Promise<boolean> => {
/**
* `replayInputs` are a failed turn's own run arguments, read back from its job. They
* stand in for the composer's current inputs, so a retry runs the turn that failed
* rather than a new one wearing its text.
*/
sendRequest = async (
options: ChatSendRequestOptions = {},
replayInputs?: Record<string, any>
): Promise<boolean> => {
const text = options.instructions?.trim() ?? ''
let images = options.images ?? []
let blobs = options.blobs ?? []
@@ -322,16 +446,18 @@ export class FlowChatViewHost implements ChatViewHost {
return false
}
const target = this.#options.attachmentsTarget?.()
// The inputs modal does not ask for this input, so a required one is enforced here.
if (target?.required && images.length === 0 && blobs.length === 0) {
// The inputs modal does not ask for this input, so a required one is enforced here. A
// replay carries the files its run already has, in `replayInputs`, and attaches none.
if (!replayInputs && target?.required && images.length === 0 && blobs.length === 0) {
sendUserToast('This chat needs a file with each message. Attach one to send.', true)
this.#aiChatInput?.prependText(text, images, [], blobs)
return false
}
const inputs = { ...(this.#options.additionalInputs?.() ?? {}) }
// A replay sends the arguments its run had, attachment references included.
const inputs = replayInputs ?? { ...(this.#options.additionalInputs?.() ?? {}) }
// The attachments are this input's only editor: a value stored for it in the inputs
// modal would otherwise ride along on every message.
if (target) delete inputs[target.name]
if (target && !replayInputs) delete inputs[target.name]
// The composer caps files as they are attached, but a queue merged over several turns
// arrives here as one send, and the chat refuses more than a single-file input holds.
const cap = this.maxMessageAttachments
@@ -359,44 +485,28 @@ export class FlowChatViewHost implements ChatViewHost {
// already running, an upload that failed, Stop pressed while it ran — and the draft
// is then handed back rather than dropped. The composer took it before calling, so
// nothing else would.
const sending = this.#chat.sendMessage(text, {
inputs: this.#options.additionalInputs?.() ? inputs : undefined,
attachments,
attachmentsInput: target
})
// `sendMessage` shows the user message before its first await, so the last one is this
// turn's. Its id is stable across the server sync, which lets Retry resend the files.
const last = this.#chat.getState().messages.at(-1)
const sentId =
last?.role === 'user' && last.pending && last.content === text ? last.id : undefined
if (sentId && (images.length > 0 || blobs.length > 0)) {
this.#sentAttachments.set(sentId, { images, blobs })
}
const turn = sending.catch((e) => {
if (sentId) this.#sentAttachments.delete(sentId)
if (this.#disposed) return
if (attachments.length > 0 && !isAbort(e)) {
sendUserToast(
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
true
)
}
// What was queued behind it comes back too, after it: the chat publishes `idle`
// when it withdraws the turn, and a queue left in place would be flushed as if
// the turn had run.
this.dequeueMessage()
this.#aiChatInput?.prependText(text, images, [], blobs)
})
const turn = this.#chat
.sendMessage(text, {
inputs: replayInputs ?? (this.#options.additionalInputs?.() ? inputs : undefined),
attachments,
attachmentsInput: target
})
.catch((e) => {
if (this.#disposed) return
if (attachments.length > 0 && !isAbort(e)) {
sendUserToast(
`Could not upload the attachments: ${e instanceof Error ? e.message : String(e)}`,
true
)
}
// What was queued behind it comes back too, after it: the chat publishes `idle`
// when it withdraws the turn, and a queue left in place would be flushed as if
// the turn had run.
this.dequeueMessage()
this.#aiChatInput?.prependText(text, images, [], blobs)
})
this.#turnDone = turn
await turn
// The files are kept only for a turn that failed, the one Retry is offered on: a base64
// payload per sent file would otherwise pile up for as long as the panel lives.
if (sentId) {
const index = this.#state.messages.findIndex((m) => m.id === sentId)
if (index === -1 || !turnFailed(this.#state.messages, index)) {
this.#sentAttachments.delete(sentId)
}
}
return true
}
/** Settles when the chat has released the last turn this host started. */
@@ -475,20 +585,62 @@ export class FlowChatViewHost implements ChatViewHost {
// Per-message actions
storedImages = () => undefined
/** The files each user message of this session went out with, for Retry. A message loaded
* from history has none recorded here and retries with its text alone. */
#sentAttachments = new Map<string, { images: AttachedImage[]; blobs: AttachedBlob[] }>()
/** Send the user message at this transcript position again. The position is in
* `displayMessages`, which holds more entries than the chat's messages. */
retryRequest = (messageIndex: number) => {
const message = this.displayMessages[messageIndex]
if (!message || message.role !== 'user' || this.loading) return
// A display message counts user messages in `index`; the files are kept by chat message id.
const sent = this.#state.messages.filter((m) => m.role === 'user')[message.index]
void this.sendRequest({
instructions: message.content,
...(sent ? this.#sentAttachments.get(sent.id) : undefined)
})
/** A retry reading back the turn it is about to replay. Deliberately not part of
* `loading`, which renders Stop: there is no run yet to stop. */
#readingReplayArgs = false
/** Turns this chat has started, so a retry can tell whether one ran while it read the
* arguments, whether or not it is still running. */
#turnsStarted = 0
/**
* Run the turn at this position again with the arguments its job ran with, not the
* composer's current inputs (`inputsShownInComposer` aside). The position is in
* `displayMessages`, which holds more entries than the chat's messages. A purged job falls
* back to the current inputs.
*/
retryRequest = async (messageIndex: number) => {
const shown = this.displayMessages[messageIndex]
if (!shown || shown.role !== 'user' || this.loading || this.#readingReplayArgs) return
const message = this.#state.messages.filter((m) => m.role === 'user')[shown.index]
if (!message) return
const conversationId = this.#state.conversationId
const turnsStarted = this.#turnsStarted
const workspace = this.#options.workspace?.()
let replayInputs: Record<string, any> | undefined
if (message.jobId && workspace) {
this.#readingReplayArgs = true
try {
const original = (await JobService.getJobArgs({ workspace, id: message.jobId })) as
| Record<string, any>
| undefined
// `user_message` is the message itself, passed as the instructions below.
const { user_message: _sent, ...rest } = original ?? {}
const current = this.#options.additionalInputs?.() ?? {}
for (const name of this.#options.inputsShownInComposer?.() ?? []) {
if (name in current) rest[name] = current[name]
else delete rest[name]
}
replayInputs = rest
} catch (error) {
// Only a job that is gone justifies running with other inputs; after a blip or
// a 500 that would silently run a different turn.
if ((error as { status?: number })?.status !== 404) {
sendUserToast('Could not read what that turn ran with. Try again.', true)
return
}
} finally {
this.#readingReplayArgs = false
}
}
// The reader may have moved on while the arguments were read: to another conversation,
// where this turn does not belong, or by sending. Any turn started since counts, settled
// or not: they wrote the chat's latest message, and running this one now would answer
// something they have moved past.
if (this.#disposed || this.#state.conversationId !== conversationId) return
if (this.#turnsStarted !== turnsStarted) {
sendUserToast('That chat started another turn. Retry once it finishes.', true)
return
}
void this.sendRequest({ instructions: message.content }, replayInputs)
}
restartGeneration = () => {}
handleUserQuestionAnswer = () => false
@@ -1,7 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Chat, ChatMessage, ChatState } from 'windmill-chat'
import { FlowChatViewHost, toDisplayMessages } from './flowChatViewHost.svelte'
vi.mock('$lib/gen', () => ({
JobService: { getJobArgs: vi.fn() }
}))
vi.mock('$lib/toast', () => ({ sendUserToast: vi.fn() }))
import { JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
const getJobArgs = vi.mocked(JobService.getJobArgs)
const toast = vi.mocked(sendUserToast)
beforeEach(() => {
getJobArgs.mockReset()
toast.mockReset()
})
function message(partial: Partial<ChatMessage> & Pick<ChatMessage, 'role'>): ChatMessage {
return {
id: partial.id ?? `${partial.role}-${Math.random()}`,
@@ -31,6 +47,10 @@ function idleState(partial: Partial<ChatState> = {}): ChatState {
function fakeChat(initial: ChatState = idleState()) {
let state = initial
const listeners = new Set<(s: ChatState) => void>()
const set = (patch: Partial<ChatState>) => {
state = { ...state, ...patch }
for (const listener of listeners) listener(state)
}
const chat = {
getState: () => state,
subscribe: (listener: (s: ChatState) => void) => {
@@ -45,15 +65,14 @@ function fakeChat(initial: ChatState = idleState()) {
loadConversations: vi.fn(async () => []),
deleteConversation: vi.fn(async () => {}),
loadOlderMessages: vi.fn(async () => {}),
renameConversation: vi.fn(async () => {}),
destroy: vi.fn()
} satisfies Chat
const set = (patch: Partial<ChatState>) => {
state = { ...state, ...patch }
for (const listener of listeners) listener(state)
}
return { chat, set }
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('toDisplayMessages', () => {
it('maps user, assistant and tool rows, marking the user message of a failed turn', () => {
const rows = [
@@ -77,17 +96,23 @@ describe('toDisplayMessages', () => {
message({ role: 'assistant', content: 'boom', success: false })
]
const display = toDisplayMessages(rows)
expect(display[0]).toEqual({ role: 'user', index: 0, content: 'hi', error: undefined })
expect(display[0]).toEqual({
role: 'user',
index: 0,
content: 'hi',
error: undefined,
images: undefined,
contextElements: undefined
})
expect(display[1]).toMatchObject({
role: 'assistant',
content: 'hello',
reasoning: 'thinking',
stepName: 'agent',
jobId: 'job-1',
createdAt: '2026-09-16T10:00:01Z',
streaming: undefined
})
expect(display[2]).toEqual({ role: 'user', index: 1, content: 'again', error: true })
expect(display[2]).toMatchObject({ role: 'user', index: 1, content: 'again', error: true })
expect(display[3]).toMatchObject({
role: 'tool',
tool_call_id: 'tool-row',
@@ -229,6 +254,81 @@ describe('toDisplayMessages', () => {
isLoading: false
})
})
// A row stored before the worker kept the call has only its sentence: the card names the
// tool and links its job, and has no details to open.
it('builds a tool card from the row alone, with its job link', () => {
const display = toDisplayMessages([
message({
role: 'tool',
content: 'Used lookup tool',
jobId: 'tool-job',
tool: { name: 'lookup', status: 'success', arguments: '{"id":1}', result: '"ok"' }
}),
message({
role: 'tool',
content: 'Used search_docs tool',
jobId: 'older-job',
tool: { name: 'search_docs', status: 'success' }
})
])
expect(display[0]).toMatchObject({
toolName: 'lookup',
parameters: { id: 1 },
result: 'ok',
showDetails: true,
jobId: 'tool-job'
})
expect(display[1]).toMatchObject({
toolName: 'search_docs',
parameters: undefined,
result: undefined,
showDetails: false,
jobId: 'older-job'
})
})
it('labels answers with their step only once the transcript names more than one', () => {
const one = toDisplayMessages([
message({ role: 'assistant', content: 'a', stepName: 'agent' }),
message({ role: 'assistant', content: 'b', stepName: 'agent' })
])
expect(one.map((m) => (m.role === 'assistant' ? m.stepName : null))).toEqual([
undefined,
undefined
])
const two = toDisplayMessages([
message({ role: 'assistant', content: 'a', stepName: 'agent' }),
message({ role: 'assistant', content: 'b', stepName: 'reviewer' })
])
expect(two.map((m) => (m.role === 'assistant' ? m.stepName : null))).toEqual([
'agent',
'reviewer'
])
})
it('shows the files a user message carried, read from the message itself', () => {
const display = toDisplayMessages(
[
message({
role: 'user',
content: 'go',
attachments: [
{ input: 'user_attachments', s3: 'chat/u1/shot.png' },
{ input: 'user_attachments', s3: 'chat/u1/notes.pdf' }
]
})
],
false,
new Set(),
{ workspace: 'ws' }
)
expect(display[0]).toMatchObject({
role: 'user',
images: [{ name: 'shot.png' }],
contextElements: [{ title: 'notes.pdf' }]
})
})
})
describe('FlowChatViewHost', () => {
@@ -265,10 +365,10 @@ describe('FlowChatViewHost', () => {
host.queueMessage('second')
expect(host.queuedMessage).toBe('first\nsecond')
set({ status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
releaseTurn()
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(host.queuedMessage).toBe('')
expect(chat.sendMessage).toHaveBeenLastCalledWith('first\nsecond', {
inputs: undefined,
@@ -309,7 +409,7 @@ describe('FlowChatViewHost', () => {
// A deployment starts while the turn is still running; the composer is disabled.
deploying = true
set({ status: 'idle' })
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(prependText).toHaveBeenCalledWith('after deploy', [], [], [])
expect(host.queuedMessage).toBe('')
@@ -329,7 +429,7 @@ describe('FlowChatViewHost', () => {
set({ status: 'idle' })
host.dispose()
releaseTurn()
await new Promise((resolve) => setTimeout(resolve, 0))
await flush()
expect(chat.sendMessage).toHaveBeenCalledTimes(1)
})
@@ -454,48 +554,30 @@ describe('FlowChatViewHost', () => {
host.dispose()
})
it('retries a failed turn with the files it was sent with', async () => {
const { chat, set } = fakeChat(idleState({ messages: [] }))
chat.sendMessage.mockImplementationOnce(async () => {
set({ messages: [message({ id: 'u1', role: 'user', content: 'read', pending: true })] })
it('retries a failed turn with the files its run had, uploading nothing', async () => {
const { chat } = fakeChat(
idleState({
messages: [
message({ role: 'user', content: 'read', jobId: 'flow-job' }),
message({ role: 'assistant', content: 'boom', success: false })
]
})
)
getJobArgs.mockResolvedValueOnce({
user_message: 'read',
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
} as any)
const host = new FlowChatViewHost(chat, {
workspace: () => 'ws',
attachmentsTarget: () => listInput
})
// The chat shows the user message before its first await.
chat.sendMessage.mockImplementationOnce(async () => {})
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const sending = host.sendRequest({ instructions: 'read', images: [image], blobs: [pdf] })
set({
messages: [
message({ id: 'u1', role: 'user', content: 'read' }),
message({ role: 'assistant', content: 'boom', success: false })
]
})
await sending
host.retryRequest(0)
await new Promise((resolve) => setTimeout(resolve, 0))
const [text, options] = chat.sendMessage.mock.calls[1] as any
await host.retryRequest(0)
const [text, options] = chat.sendMessage.mock.calls[0] as any
expect(text).toBe('read')
expect(options.attachments.map((a: any) => a.name)).toEqual(['shot.webp', 'contract.pdf'])
host.dispose()
})
it('lets go of the files of a turn that succeeded', async () => {
const { chat, set } = fakeChat(idleState({ messages: [] }))
chat.sendMessage.mockImplementationOnce(async () => {
set({ messages: [message({ id: 'u1', role: 'user', content: 'read', pending: true })] })
// The references the run already has, so the files are not uploaded a second time.
expect(options.inputs).toEqual({
user_attachments: [{ s3: 'chat/u1/contract.pdf', filename: 'contract.pdf' }]
})
chat.sendMessage.mockImplementationOnce(async () => {})
const host = new FlowChatViewHost(chat, { attachmentsTarget: () => listInput })
const sending = host.sendRequest({ instructions: 'read', blobs: [pdf] })
set({
messages: [
message({ id: 'u1', role: 'user', content: 'read' }),
message({ role: 'assistant', content: 'done' })
]
})
await sending
host.retryRequest(0)
await new Promise((resolve) => setTimeout(resolve, 0))
const [, options] = chat.sendMessage.mock.calls[1] as any
expect(options.attachments).toEqual([])
host.dispose()
})
@@ -590,4 +672,163 @@ describe('FlowChatViewHost', () => {
set({ status: 'streaming' })
expect(host.loading).toBe(false)
})
describe('retry', () => {
const failedTurn = () =>
idleState({
messages: [
message({ role: 'user', content: 'go', jobId: 'flow-job' }),
message({ role: 'assistant', content: 'boom', success: false })
]
})
it("replays the turn with the inputs its run had, not the composer's", async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockResolvedValueOnce({ user_message: 'go', tone: 'terse', model: 'old' } as any)
const host = new FlowChatViewHost(chat, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'brief', model: 'new' }),
inputsShownInComposer: () => ['model']
})
await host.retryRequest(0)
expect(getJobArgs).toHaveBeenCalledWith({ workspace: 'ws', id: 'flow-job' })
// The composer's own control wins for what it shows; everything else replays.
expect(chat.sendMessage).toHaveBeenCalledWith(
'go',
expect.objectContaining({ inputs: { tone: 'terse', model: 'new' } })
)
host.dispose()
})
it('falls back to a plain resend once the job is purged', async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('gone'), { status: 404 }))
const host = new FlowChatViewHost(chat, {
workspace: () => 'ws',
additionalInputs: () => ({ tone: 'brief' })
})
await host.retryRequest(0)
expect(chat.sendMessage).toHaveBeenCalledWith(
'go',
expect.objectContaining({ inputs: { tone: 'brief' } })
)
expect(toast).not.toHaveBeenCalled()
host.dispose()
})
it('does nothing but say so when the run cannot be read', async () => {
const { chat } = fakeChat(failedTurn())
getJobArgs.mockRejectedValueOnce(Object.assign(new Error('down'), { status: 500 }))
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
await host.retryRequest(0)
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith('Could not read what that turn ran with. Try again.', true)
host.dispose()
})
it('refuses once a turn started while the run was being read', async () => {
const { chat, set } = fakeChat(failedTurn())
let answer = (_: unknown) => {}
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
const retried = host.retryRequest(0)
set({ status: 'streaming' })
answer({ user_message: 'go' })
await retried
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith(
'That chat started another turn. Retry once it finishes.',
true
)
host.dispose()
})
// A quick turn can start and settle inside that read: the chat is idle again, but its
// latest message is not the one this retry was clicked on.
it('refuses once a turn ran and settled while the run was being read', async () => {
const { chat, set } = fakeChat(failedTurn())
let answer = (_: unknown) => {}
getJobArgs.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)) as any)
const host = new FlowChatViewHost(chat, { workspace: () => 'ws' })
const retried = host.retryRequest(0)
set({ status: 'streaming' })
set({ status: 'idle' })
answer({ user_message: 'go' })
await retried
expect(chat.sendMessage).not.toHaveBeenCalled()
expect(toast).toHaveBeenCalledWith(
'That chat started another turn. Retry once it finishes.',
true
)
host.dispose()
})
})
describe('streaming reveal', () => {
/** A scheduler the test steps by hand, and a clock it advances. */
function manualReveal() {
let time = 0
const queue: (() => void)[] = []
return {
options: {
instant: false,
now: () => time,
schedule: (cb: () => void) => (queue.push(cb), cb),
cancel: (handle: unknown) => {
const i = queue.indexOf(handle as () => void)
if (i >= 0) queue.splice(i, 1)
}
},
tick: (ms: number) => {
time += ms
const due = queue.splice(0)
for (const cb of due) cb()
}
}
}
it('paces a streaming answer and shows it whole once it settles', () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const reveal = manualReveal()
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
const streaming = message({ role: 'assistant', id: 'a1', content: '', pending: true })
set({ messages: [{ ...streaming, content: 'The answer, in one burst of text.' }] })
const shown = () => (host.displayMessages[0] as { content: string }).content
// Nothing is revealed until the pacer's first frame, and that frame shows a slice.
expect(shown()).toBe('')
reveal.tick(16)
expect(shown().length).toBeGreaterThan(0)
expect(shown().length).toBeLessThan('The answer, in one burst of text.'.length)
expect('The answer, in one burst of text.'.startsWith(shown())).toBe(true)
// Settled: the whole text, whatever the pacer had got to.
set({
status: 'idle',
messages: [{ ...streaming, content: 'The answer, in one burst of text.', pending: false }]
})
expect(shown()).toBe('The answer, in one burst of text.')
host.dispose()
})
it('shows a paced row whole once a tool card follows it', () => {
const { chat, set } = fakeChat(idleState({ status: 'streaming' }))
const reveal = manualReveal()
const host = new FlowChatViewHost(chat, { revealOptions: reveal.options })
const answer = message({
role: 'assistant',
id: 'a1',
content: 'Let me look.',
pending: true
})
set({ messages: [answer] })
expect((host.displayMessages[0] as { content: string }).content).toBe('')
set({
messages: [
answer,
message({ role: 'tool', pending: true, tool: { name: 'lookup', status: 'running' } })
]
})
expect((host.displayMessages[0] as { content: string }).content).toBe('Let me look.')
host.dispose()
})
})
})
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { attachmentLanes } from './messageAttachments'
describe('attachmentLanes', () => {
it('splits images into thumbnails and everything else into named file chips', () => {
const { images, contextElements } = attachmentLanes('ws', [
{ input: 'user_attachments', s3: 'chat/u1/shot.PNG', storage: 'secondary' },
{ input: 'user_attachments', s3: 'chat/u1/0/notes.pdf', filename: 'Q3 notes.pdf' },
{ input: 'report', s3: 'chat/u1/data.csv' }
])
expect(images.map((i) => i.name)).toEqual(['shot.PNG'])
expect(images[0].dataUrl).toContain('/api/w/ws/job_helpers/download_s3_file?')
expect(images[0].dataUrl).toContain('file_key=chat%2Fu1%2Fshot.PNG')
expect(images[0].dataUrl).toContain('storage=secondary')
expect(contextElements.map((c) => c.title)).toEqual(['Q3 notes.pdf', 'data.csv'])
})
it('shows an image as a file chip when there is no workspace to link it in', () => {
const { images, contextElements } = attachmentLanes(undefined, [
{ input: 'user_attachments', s3: 'chat/u1/shot.png' }
])
expect(images).toEqual([])
expect(contextElements.map((c) => c.title)).toEqual(['shot.png'])
})
})
@@ -0,0 +1,48 @@
/** The files a user message carried, as the lanes its bubble renders: image thumbnails and file chips. */
import type { ChatAttachment } from 'windmill-chat'
import { base } from '$lib/base'
import {
createAttachedFileContextElement,
type ContextElement
} from '$lib/components/copilot/chat/context'
import type { AttachedImage } from '$lib/components/copilot/chat/imageUtils'
const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg', '.avif']
export type MessageAttachments = { images: AttachedImage[]; contextElements: ContextElement[] }
function displayName(attachment: ChatAttachment): string {
return attachment.filename || attachment.s3.split('/').pop() || attachment.s3
}
function looksLikeImage(attachment: ChatAttachment): boolean {
const name = displayName(attachment).toLowerCase()
return IMAGE_EXTENSIONS.some((ext) => name.endsWith(ext))
}
/** Same-origin, cookie-authed GET, usable directly as an <img src>. */
function downloadUrl(workspace: string, attachment: ChatAttachment): string {
const params = new URLSearchParams({ file_key: attachment.s3 })
if (attachment.storage) params.set('storage', attachment.storage)
return `${base}/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}`
}
/** Without a workspace there is no link to build, so an image falls back to a file chip. */
export function attachmentLanes(
workspace: string | undefined,
attachments: readonly ChatAttachment[] | undefined
): MessageAttachments {
const images: AttachedImage[] = []
const contextElements: ContextElement[] = []
for (const attachment of attachments ?? []) {
const name = displayName(attachment)
if (workspace && looksLikeImage(attachment)) {
images.push({ dataUrl: downloadUrl(workspace, attachment), mediaType: 'image/png', name })
} else {
contextElements.push(
createAttachedFileContextElement(name, `Attached file · ${attachment.s3}`)
)
}
}
return { images, contextElements }
}
@@ -639,63 +639,74 @@
<div
class={twMerge(
'w-full flex flex-col',
chatInputEnabled ? 'p-3 h-full' : 'max-w-3xl p-6 min-h-[300px] justify-center',
chatInputEnabled ? 'h-full min-h-0' : 'max-w-3xl p-6 min-h-[300px] justify-center',
'mx-auto'
)}
>
{#if flow?.path}
<CiTestResults path={flow.path} kind="flow" />
{/if}
<!-- The chat reaches the edges of the pane, so the notices above it carry their
own padding. `contents` leaves the form layout exactly as it was. -->
<!-- Top spacing hangs off the first notice, not the wrapper: `{#if}` leaves a
comment anchor behind, so an empty wrapper is not `:empty` and its own
padding would show as a gap above a chat with nothing to announce. -->
<div
class={chatInputEnabled ? 'flex flex-col px-3 [&>*:first-child]:mt-3' : 'contents'}
>
{#if flow?.path}
<CiTestResults path={flow.path} kind="flow" />
{/if}
{#if flow?.archived}
<Alert type="error" title="Archived">This flow was archived</Alert>
<div class="h-4"></div>
{/if}
{#if flow?.archived}
<Alert type="error" title="Archived">This flow was archived</Alert>
<div class="h-4"></div>
{/if}
{#if pinnedVersion !== undefined}
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
This is a historical version of the flow, not the latest.
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
View latest
</a>
</Alert>
<div class="h-4"></div>
{/if}
{#if pinnedVersion !== undefined}
<Alert type="info" title="Viewing pinned version {pinnedVersion}">
This is a historical version of the flow, not the latest.
<a class="underline" href="/flows/get/{path}?workspace={$workspaceStore}">
View latest
</a>
</Alert>
<div class="h-4"></div>
{/if}
{#if !emptyString(flow?.description)}
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown
md={defaultIfEmptyString(flow?.description, 'No description')}
noPadding
/>
</div>
<div class="h-4"></div>
{/if}
<!-- In chat mode the description belongs to the chat, which shows it under the
empty transcript. -->
{#if !chatInputEnabled && !emptyString(flow?.description)}
<div class="p-4 rounded-md bg-surface-secondary">
<GfmMarkdown
md={defaultIfEmptyString(flow?.description, 'No description')}
noPadding
/>
</div>
<div class="h-4"></div>
{/if}
{#if deploymentInProgress}
<div class="pb-4" transition:slide={{ duration: 150 }}>
<HeaderBadge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
{#if deploymentJobId}
<a
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
class="underline"
target="_blank">view job</a
>
{/if}
</HeaderBadge>
</div>
{/if}
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
<Alert type="error" title="Deployment failed">
<p>
This flow has not been deployed successfully because of the following errors:
</p>
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
</Alert>
<div class="h-4"></div>
{/if}
{#if deploymentInProgress}
<div class="pb-4" transition:slide={{ duration: 150 }}>
<HeaderBadge color="yellow">
<Loader2 size={12} class="inline animate-spin mr-1" />
Deployment in progress
{#if deploymentJobId}
<a
href="/run/{deploymentJobId}?workspace={$workspaceStore}"
class="underline"
target="_blank">view job</a
>
{/if}
</HeaderBadge>
</div>
{/if}
{#if flow.lock_error_logs && flow.lock_error_logs != ''}
<Alert type="error" title="Deployment failed">
<p>
This flow has not been deployed successfully because of the following errors:
</p>
<LogViewer content={flow.lock_error_logs} isLoading={false} tag={undefined} />
</Alert>
<div class="h-4"></div>
{/if}
</div>
{#if chatInputEnabled}
<!-- Chat Layout with Sidebar -->
@@ -707,6 +718,7 @@
inputSchema={flow?.schema}
flowModules={flow?.value?.modules}
wideLayout
frame="none"
/>
{:else}
{@const hasSchema =