Files
windmill/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts
T
Guilhem e2a91ca2b1 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
2026-09-18 11:31:00 +02:00

693 lines
28 KiB
TypeScript

import type { AttachmentUpload, Chat, ChatMessage, ChatState } from 'windmill-chat'
import type {
ChatSendRequestOptions,
ChatViewHost
} from '$lib/components/copilot/chat/chatViewHost'
import type { DisplayMessage } from '$lib/components/copilot/chat/shared'
import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { isPlanCardTool } from '$lib/components/copilot/chat/planMode'
import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte'
import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte'
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 = {
/** The flow inputs sent next to `user_message` with every turn. */
additionalInputs?: () => Record<string, any> | undefined
/** The flow input the composer's attachments feed. Undefined where the flow has none:
* the chat then takes no attachments at all. */
attachmentsTarget?: () => AttachmentsTarget | undefined
/** 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, 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 {
return status === 'submitted' || status === 'streaming'
}
/** The chat's own signal for a send stopped before it ran; nothing to tell the reader. */
function isAbort(e: unknown): boolean {
return e instanceof Error && e.name === 'AbortError'
}
type Queue = { text: string; images: AttachedImage[]; blobs: AttachedBlob[] }
function emptyQueue(): Queue {
return { text: '', images: [], blobs: [] }
}
/** A tool's arguments or result as the card shows them: parsed where the string is JSON. */
function parseToolPayload(raw: string | undefined): unknown {
if (raw === undefined || raw === '') return undefined
try {
return JSON.parse(raw)
} catch {
return raw
}
}
/**
* Whether the turn the user message at `index` started failed: its last row before the
* next user message reports `success: false`. The last row, not any row: a tool call can
* fail and the agent still answer, and that turn completed.
*/
export function turnFailed(messages: readonly ChatMessage[], index: number): boolean {
let last: ChatMessage | undefined
for (let i = index + 1; i < messages.length; i++) {
const message = messages[i]
if (message.role === 'user') break
last = message
}
return last?.success === false
}
/** Whether the latest turn failed, per `turnFailed`. False before any turn. */
function lastTurnFailed(messages: readonly ChatMessage[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') return turnFailed(messages, i)
}
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.
*/
function toolErrorText(result: unknown): string | undefined {
if (typeof result === 'string') return result || undefined
if (result && typeof result === 'object') {
const message = (result as { message?: unknown }).message
return typeof message === 'string' ? message : JSON.stringify(result, null, 2)
}
return undefined
}
/** The name the worker gives the structured-output tool: suffixed when an agent tool already has it. */
const STRUCTURED_OUTPUT_CALL = /^structured_output(_\d+)?$/
/**
* `busy`: the latest turn is still running, so a failed tool call is not yet its outcome.
* `stopped`: ids of the user messages of the turns the reader stopped, as message id or row id.
* A stopped turn never offers Retry, though Stop leaves its failed tool row or the cancelled
* flow's failure as its last message.
*/
export function toDisplayMessages(
messages: readonly ChatMessage[],
busy = false,
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
}
return messages.flatMap((message, i): DisplayMessage[] => {
switch (message.role) {
case 'user': {
const index = userIndex++
const settled =
!(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,
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
// A call the turn finished without, stopped or lost before its row was written. The
// call a structured answer streams as never gets a result of its own, the answer
// being the turn's text, so it is not one.
const unfinished =
message.tool &&
!message.pending &&
!message.content &&
!STRUCTURED_OUTPUT_CALL.test(message.tool.name)
? `${message.tool.name} did not finish`
: undefined
const call: DisplayMessage = {
role: 'tool',
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 || (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(toolName) ? undefined : toolName,
parameters,
result,
showDetails: parameters !== undefined || result !== undefined,
error: failed ? (toolErrorText(result) ?? message.content) : unfinished,
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.
return message.reasoning
? [
{
role: 'assistant',
content: '',
reasoning: message.reasoning,
stepName: stepNames ? message.stepName : undefined,
jobId: message.jobId,
createdAt: message.createdAt
},
call
]
: [call]
}
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: 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: 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
* makes, so what it can offer is what the SDK's `Chat` can: a message in, an answer
* streamed back, Stop. Every copilot-only field is answered with "no" (see ChatViewHost).
*
* The host owns its subscription to the chat, so a panel swapping chats mounts a new one.
*/
export class FlowChatViewHost implements ChatViewHost {
#chat: Chat
#options: FlowChatViewHostOptions
#state = $state.raw<ChatState>() as ChatState
#unsubscribe: () => void
constructor(chat: Chat, options: FlowChatViewHostOptions = {}) {
this.#chat = chat
this.#options = options
this.#state = chat.getState()
this.#unsubscribe = chat.subscribe((state) => this.#onState(state))
}
#disposed = false
/** Stops following the chat, and drops what was queued: a flush still waiting on the
* turn's release would otherwise start a run from a panel that is gone. A send still
* uploading its attachments stops with the chat, which is the caller's to destroy; its
* draft is not handed back, since the composer it came from is gone too. */
dispose() {
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). */
get state(): ChatState {
return this.#state
}
#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)
)
if (landed.length > 0) {
this.#stoppedTurns = new Set([...this.#stoppedTurns, ...landed.map((m) => m.serverId!)])
}
if (previous.conversationId !== state.conversationId) {
// A conversation opens at its end, whatever the reader was doing in the last one.
this.#automaticScroll = true
// 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
// counts the turn as open until it returns, and a send made before that would be
// refused as a second turn. After a failure it goes back to the composer instead,
// where the reader would rather look at the error than pile on. A failed flow
// settles as `idle` too, with its error as the answer, so the messages decide.
const succeeded = state.status === 'idle' && !lastTurnFailed(state.messages)
if (succeeded) void this.#turnDone.then(this.flushQueuedMessage)
else this.dequeueMessage()
}
}
// 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, {
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,
* so a turn left before its row was read is not recognised there. Only this session knows;
* a reload shows the stopped turn as its rows left it. */
#stoppedTurns = $state.raw<ReadonlySet<string>>(new Set())
get messages(): readonly unknown[] {
return this.#state.messages
}
contextTokens = 0
get operatingWorkspace(): string | undefined {
return this.#options.workspace?.()
}
get loading(): boolean {
return isBusy(this.#state.status)
}
runHeldElsewhere = false
loadingLabel = undefined
compacting = false
// The answer streams into the message list itself, so the live lanes stay empty.
currentReply = ''
currentReasoning = ''
currentReasoningActive = false
reasoningHiddenIndicatorLabel = undefined
#automaticScroll = $state(true)
get automaticScroll(): boolean {
return this.#automaticScroll
}
enableAutomaticScroll = () => {
this.#automaticScroll = true
}
disableAutomaticScroll = () => {
this.#automaticScroll = false
}
// Composer
instructions = ''
// The user message lands in the transcript before `sendMessage` awaits anything.
sendInFlight = false
/**
* `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 ?? []
// The composer refuses an attachment-only send (requiresMessageText), so this is
// the same rule at the other end: nothing runs without a message.
if (!text) return false
if (this.loading) {
this.queueMessage(text, images, undefined, undefined, blobs)
return true
}
if (this.#options.sendDisabled?.()) {
// Refused, not dropped: the draft waits in the composer for sending to reopen.
this.#aiChatInput?.prependText(text, images, [], blobs)
return false
}
const target = this.#options.attachmentsTarget?.()
// 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
}
// 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 && !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
if (cap !== undefined && images.length + blobs.length > cap) {
const dropped = images.length + blobs.length - cap
images = images.slice(0, cap)
blobs = blobs.slice(0, Math.max(0, cap - images.length))
sendUserToast(
cap === 1
? `This chat sends one attachment per message; ${dropped} file(s) were not sent.`
: `This chat sends up to ${cap} attachments per message; ${dropped} file(s) were not sent.`,
true
)
}
const attachments: AttachmentUpload[] = target
? [...images, ...blobs].map((attachment, index) => ({
name: attachment.name ?? `attachment-${index + 1}`,
data: attachment.dataUrl,
mediaType: attachment.mediaType
}))
: []
this.#automaticScroll = true
// A run that fails is reported through the chat's `onError` and as a failed message;
// the promise itself only rejects when the chat refuses the turn outright — a turn
// 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 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
return true
}
/** Settles when the chat has released the last turn this host started. */
#turnDone: Promise<unknown> = Promise.resolve()
cancel = () => {
// Stop means stop: what was typed during the run goes back to the composer rather
// than waiting there to go out after some later turn settles.
this.dequeueMessage()
const { messages, status } = this.#state
const turn = isBusy(status) ? [...messages].reverse().find((m) => m.role === 'user') : undefined
if (turn) {
this.#stoppedTurns = new Set(
[...this.#stoppedTurns, turn.id, turn.serverId].filter((id) => id !== undefined)
)
}
void this.#chat.stop()
}
// Typed off the interface: a Svelte component's own type resolves differently
// across import specifiers, and the two would then not be assignable.
#aiChatInput: Parameters<ChatViewHost['setAiChatInput']>[0] = null
setAiChatInput: ChatViewHost['setAiChatInput'] = (aiChatInput) => {
this.#aiChatInput = aiChatInput
}
// One message typed while the turn runs, sent whole with its attachments once the turn
// settles. Enter again appends a line rather than replacing what waits.
#queue = $state<Queue>(emptyQueue())
get queuedMessage(): string {
return this.#queue.text
}
queuedContext = undefined
get queuedImages(): AttachedImage[] {
return this.#queue.images
}
queuedFiles: AttachedTextFile[] = []
get queuedBlobs(): AttachedBlob[] {
return this.#queue.blobs
}
queueMessage = (
text: string,
images: AttachedImage[] = [],
_context?: unknown,
_files?: unknown,
blobs: AttachedBlob[] = []
) => {
const trimmed = text.trim()
if (!trimmed && images.length === 0 && blobs.length === 0) return
const queue = this.#queue
this.#queue = {
text: !trimmed ? queue.text : queue.text ? `${queue.text}\n${trimmed}` : trimmed,
images: [...queue.images, ...images],
blobs: [...queue.blobs, ...blobs]
}
}
/** Put the queued draft back in the composer, attachments included. */
dequeueMessage = () => {
const { text, images, blobs } = this.#takeQueue()
if (!text && images.length === 0 && blobs.length === 0) return
this.#aiChatInput?.prependText(text, images, [], blobs)
}
flushQueuedMessage = () => {
// Same rule as sendRequest, read before the queue is drained: a turn with no message
// cannot run, and taking the queue for it would drop the attachments on the floor.
if (!this.#queue.text || this.#disposed) return
const { text, images, blobs } = this.#takeQueue()
void this.sendRequest({ instructions: text, images, blobs })
}
#takeQueue(): Queue {
const taken = this.#queue
this.#queue = emptyQueue()
return taken
}
setComposerStaged = () => {}
clearComposerStaged = () => {}
attachmentBytesExcluding = () => 0
// Per-message actions
storedImages = () => 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
handleToolConfirmation = () => {}
hasPendingRunForm = false
isRunFormPending = () => false
// Copilot-only surfaces
mode = undefined
isSessionChat = false
supportsModelSettings = false
supportsMessageEditing = false
// The input's shape alone: whether this chat takes attachments at all is a fact about the
// flow, not about the workspace. Object storage decides whether it can right now, which is
// `attachmentsUnavailableReason` — a state on the control rather than a reason to move the
// input to the modal, where a file picker would be just as unable to upload.
get supportsMessageAttachments(): boolean {
return !!this.#options.attachmentsTarget?.()
}
get attachmentsUnavailableReason(): string | undefined {
return this.#options.attachmentsUnavailable?.()
}
// An AI agent step refuses a run with no `user_message`.
requiresMessageText = true
// Attachments go to object storage for the worker to read, so a linked folder — a live
// handle on the user's own disk — has no meaning here.
supportsLinkedFolders = false
attachmentsAsBlobs = true
// A single-file flow input takes one attachment per message.
get maxMessageAttachments(): number | undefined {
return this.#options.attachmentsTarget?.()?.multiple === false ? 1 : undefined
}
// What a provider actually takes. Anthropic's document block accepts base64
// `application/pdf` and nothing else, so the wider set `is_document_mime`
// (windmill-ai/src/ai_types.rs) claims — csv, html, plain, docx, xlsx — is rejected with a
// 400 rather than read. Widen this only alongside a worker that inlines text as text.
attachmentAccept = 'image/*,application/pdf,.pdf'
tools = []
// The enum's value, written out so this module never imports the copilot manager at
// runtime: its unit test would otherwise load the manager and the editor it pulls in.
autonomyMode = 'default' as AIAutonomyMode.DEFAULT
setAutonomyMode = () => {}
autoAcceptEditsActive = false
autoAcceptEditsAvailable = false
autoAcceptToolConfirmationsAvailable = false
planModeAvailable = false
attachedFiles = new AttachedFilesStore()
artifacts = new SessionArtifactsStore()
}