Files
windmill/frontend/src/lib/components/copilot/chat/chatViewHost.ts
T
GuilhemandClaude Fable 5.1 a9ec0aec3a feat: render the flow chat through the shared session chat components (#11175)
* feat: render the flow chat through the shared session chat components

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: settle the flow chat queue on the turn's outcome and guard IME enter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix: keep copilot free-tier banners and deploy-locked sends out of the flow chat

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 17:45:01 +02:00

153 lines
6.5 KiB
TypeScript

import { getContext, setContext } from 'svelte'
import type { AIMode, AIAutonomyMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import type { DisplayMessage, Tool } from './shared'
import type { ContextElement } from './context'
import type { AttachedImage } from './imageUtils'
import type { AttachedTextFile } from './textFileUtils'
import type { PasteAttachment } from './pasteTokens'
import type { AttachedFilesStore } from './files/attachedFiles.svelte'
import type { SessionArtifactsStore } from './artifacts/artifactsState.svelte'
import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter'
import type { FlowAIChatHelpers } from './flow/core'
import type { AppAIChatHelpers } from './app/core'
import type AIChatInput from './AIChatInput.svelte'
export type ChatSendRequestOptions = {
instructions?: string
pastes?: PasteAttachment[]
images?: AttachedImage[]
files?: AttachedTextFile[]
/** Selected-context snapshot for this turn, in place of the live selection. Set
* whenever a send settles its context ahead of the turn. A host with no context
* of its own ignores it. */
contextOverride?: ContextElement[]
/** Where `contextOverride` came from. 'pinned': chips picked for THIS message, so
* they are consumed from the live selection on send. 'replay': an edit or retry
* resending an older message's context, already consumed long ago. */
contextOverrideOrigin?: 'pinned' | 'replay'
}
/**
* What the chat view components (AIChatDisplay and everything it renders) need
* from whatever is driving the conversation. AIChatManager implements it for the
* copilot's own LLM loop; FlowChatViewHost implements it over a flow run's
* conversation so both chats render through the same components.
*
* Each affordance is gated by the field that answers for it — attachments by
* `supportsMessageAttachments`, the model button by `supportsModelSettings`, and so on —
* so a host turns on exactly what it can serve, and a new one is a matter of answering
* these fields rather than of being a copilot. `mode` is the exception, still read
* directly for chrome that only the copilot has.
*/
export interface ChatViewHost {
// Transcript
displayMessages: DisplayMessage[]
/** API-level messages. Only the count is read (context usage visibility). */
messages: readonly unknown[]
contextTokens: number
/** The workspace a message's paths and jobs resolve against, which a fork session
* pins away from the navigated one. */
readonly operatingWorkspace: string | undefined
loading: boolean
/** A turn this tab can neither follow nor stop, held by another tab on the same chat. */
readonly runHeldElsewhere: boolean
loadingLabel: string | undefined
compacting: boolean
currentReply: string
currentReasoning: string
currentReasoningActive: boolean
readonly reasoningHiddenIndicatorLabel: string | undefined
readonly automaticScroll: boolean
enableAutomaticScroll: () => void
disableAutomaticScroll: () => void
// Composer
instructions: string
readonly sendInFlight: boolean
/** Resolves to whether the draft was consumed as a turn. */
sendRequest: (options?: ChatSendRequestOptions) => Promise<boolean | undefined>
cancel: (reason?: string) => void
setAiChatInput: (aiChatInput: AIChatInput | null) => void
readonly queuedMessage: string
queuedContext: ContextElement[] | undefined
readonly queuedImages: AttachedImage[]
readonly queuedFiles: AttachedTextFile[]
queueMessage: (
text: string,
images?: AttachedImage[],
context?: ContextElement[],
files?: AttachedTextFile[]
) => void
dequeueMessage: () => void
setComposerStaged: (key: string, editingIndex: number | null, bytes: number) => void
clearComposerStaged: (key: string) => void
attachmentBytesExcluding: (selfKey: string) => number
// Per-message actions
storedImages: (displayMessageIndex: number) => AttachedImage[] | undefined
retryRequest: (messageIndex: number) => void
restartGeneration: (
displayMessageIndex: number,
newContent?: string,
pastes?: PasteAttachment[],
images?: AttachedImage[],
editedContext?: ContextElement[],
files?: AttachedTextFile[]
) => void | Promise<void>
handleUserQuestionAnswer: (toolId: string, choices: string[]) => boolean
handleToolConfirmation: (toolId: string, confirmed: boolean) => void
/** A tool is waiting on a run form the user is filling in. Escape belongs to that form
* then, not to the turn — see AIChatDisplay's window handler. */
readonly hasPendingRunForm: boolean
isRunFormPending: (toolCallId: string) => boolean
// Copilot-only surfaces. Left undefined/false by hosts that have no LLM loop
// of their own; the chrome they drive hides itself.
mode?: AIMode
isSessionChat: boolean
/** Model + reasoning picker. Off where the model is configured elsewhere. */
supportsModelSettings: boolean
/** Click a user message to edit and resend it. Needs a host that can rewind
* its own transcript, which a host replaying a server-side run cannot. */
supportsMessageEditing: boolean
/** The `+` menu's file entry and drag-and-drop onto the panel. */
supportsMessageAttachments: boolean
/** The turn needs text: attachments alone cannot be sent. True where the consumer
* requires a message of its own — an AI agent step refuses a run with neither a
* `user_message` nor manual memory. */
requiresMessageText: boolean
/** The `+` menu's folder entries, backed by `attachedFiles`. A linked folder is a
* live handle on the user's disk, so only a host reading files in the browser has one. */
supportsLinkedFolders: boolean
/** `accept` for the file picker. */
attachmentAccept: string
tools: Tool<any>[]
autonomyMode: AIAutonomyMode
setAutonomyMode: (mode: AIAutonomyMode) => void
readonly autoAcceptEditsActive: boolean
readonly autoAcceptEditsAvailable: boolean
readonly autoAcceptToolConfirmationsAvailable: boolean
readonly planModeAvailable: boolean
attachedFiles: AttachedFilesStore
artifacts: SessionArtifactsStore
openArtifact?: (artifactId: string, name: string, version?: ArtifactVersionTarget) => void
flowAiChatHelpers?: FlowAIChatHelpers
appAiChatHelpers?: AppAIChatHelpers
}
const CHAT_VIEW_HOST_CONTEXT_KEY = 'chatViewHost'
export function setChatViewHost(host: ChatViewHost) {
setContext(CHAT_VIEW_HOST_CONTEXT_KEY, host)
}
/**
* Resolve the host driving the chat in this subtree. Falls back to the
* AIChatManager (scoped instance or app-wide singleton) so every existing
* copilot chat keeps working without setting anything.
*/
export function getChatViewHost(): ChatViewHost {
return getContext<ChatViewHost>(CHAT_VIEW_HOST_CONTEXT_KEY) ?? getAiChatManager()
}