refactor: extract ChatViewHost from AIChatManager for the chat view components

The chat view components each read getAiChatManager(), which returns the
AIChatManager class. Its 76 private members mean nothing else can be typed as
one, so no other conversation can render through them.

ChatViewHost is the ~45 members those components actually read, resolved by
getChatViewHost() on its own context key with the manager as the fallback, so
every existing copilot chat keeps working without setting anything. `mode` is
optional on it: a host that leaves it undefined makes AIChatDisplay's existing
`mode === AIMode.X` gates evaluate false, which drops the context picker, file
attachments, MCP, background jobs and suggestions without new flags. Only the
model picker and message editing needed gates of their own.

AIChatDisplay also gains a `placeholder`, and exposes its transcript scroller
plus a scroll callback for a host that paginates older messages.

Behavior is unchanged for the copilot chat.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hw1yzWHyqqZmQrviUEhr5b
This commit is contained in:
Guilhem Lemouel
2026-08-31 15:55:41 +02:00
co-authored by Claude Opus 5
parent aa4a6ffd66
commit 4ac845dde6
10 changed files with 299 additions and 158 deletions
@@ -40,7 +40,7 @@
import Markdown from 'svelte-exmarkdown'
import { twMerge } from 'tailwind-merge'
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import AIChatInput from './AIChatInput.svelte'
import AttachedFilesBar from './files/AttachedFilesBar.svelte'
@@ -61,7 +61,7 @@
import { sendUserToast } from '$lib/toast'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// One row per autonomy posture, in picker order, so adding one touches only this
// table. `isAvailable` hides the postures that would do nothing in the current AI
// mode, which is why the picker can be shorter than this list.
@@ -158,7 +158,10 @@
emptyHint,
inputPreface,
initialInstructions = undefined,
onDraftChange = undefined
onDraftChange = undefined,
placeholder = undefined,
scrollElement = $bindable(),
onTranscriptScroll = undefined
}: {
messages: DisplayMessage[]
pastChats: { id: string; title: string }[]
@@ -188,6 +191,12 @@
// Seed / observe the main composer's draft text (see AIChatInput).
initialInstructions?: string
onDraftChange?: (text: string) => void
/** Composer placeholder. Falls back to the per-AI-mode wording. */
placeholder?: string
/** The transcript's scroll container. A host that paginates older messages
* needs it to measure and restore the scroll position. */
scrollElement?: HTMLDivElement | undefined
onTranscriptScroll?: () => void
} = $props()
let aiChatInput: AIChatInput | undefined = $state()
@@ -202,7 +211,7 @@
let panelEl: HTMLDivElement | undefined = $state()
$effect(() => {
function onWindowKeydownCapture(e: KeyboardEvent) {
if (e.key !== 'Escape' || !aiChatManager.loading) return
if (e.key !== 'Escape' || !chatHost.loading) return
const active = document.activeElement
const focusOnChat =
!active || active === document.body || (panelEl?.contains(active) ?? false)
@@ -211,13 +220,12 @@
// Immediate form: other chat panels' identical listeners must not
// also cancel on body focus, nor a drawer/modal close on this press.
e.stopImmediatePropagation()
aiChatManager.cancel()
chatHost.cancel()
}
window.addEventListener('keydown', onWindowKeydownCapture, true)
return () => window.removeEventListener('keydown', onWindowKeydownCapture, true)
})
let scrollEl: HTMLDivElement | undefined = $state()
// Programmatic-scroll guard. `scrollDown()` triggers an async `scroll`
// event; if a token-append between the scrollTo and the dispatch makes
// scrollHeight grow, the gap can briefly exceed STICK_TO_BOTTOM_PX and
@@ -230,22 +238,23 @@
// Instant scroll — smooth would animate every token append, racing with
// the next scrollDown and confusing the onscroll bottom-detection below.
function scrollDown() {
if (!scrollEl) return
if (!scrollElement) return
programmaticScrollAt = Date.now()
scrollEl.scrollTo({ top: scrollEl.scrollHeight, behavior: 'auto' })
scrollElement.scrollTo({ top: scrollElement.scrollHeight, behavior: 'auto' })
}
let height = $state(0)
$effect(() => {
if (aiChatManager.automaticScroll && height) {
if (chatHost.automaticScroll && height) {
scrollDown()
}
// Recompute the scroll-to-latest visibility on every content-height
// change. `onScroll` only fires for actual scroll events, so without
// this the arrow can go stale when content grows past the threshold
// while auto-scroll is disabled (user scrolled up mid-stream).
if (scrollEl && height) {
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (scrollElement && height) {
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
showScrollToLatest = distance > SCROLL_TO_LATEST_THRESHOLD_PX
}
})
@@ -260,8 +269,9 @@
const SCROLL_TO_LATEST_THRESHOLD_PX = 200
let showScrollToLatest = $state(false)
function onScroll() {
if (!scrollEl) return
const distance = scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight
if (!scrollElement) return
const distance =
scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight
// Always refresh the arrow visibility — even during the cooldown,
// because clicking the arrow itself triggers a programmatic scroll
// whose only event would otherwise be swallowed, leaving the arrow
@@ -274,14 +284,15 @@
return
}
if (distance <= STICK_TO_BOTTOM_PX) {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
} else {
aiChatManager.disableAutomaticScroll()
chatHost.disableAutomaticScroll()
}
onTranscriptScroll?.()
}
function submitSuggestion(suggestion: string) {
aiChatManager.sendRequest({ instructions: suggestion })
chatHost.sendRequest({ instructions: suggestion })
}
export function focusInput() {
@@ -290,28 +301,26 @@
$effect(() => {
if (aiChatInput) {
aiChatManager.setAiChatInput(aiChatInput)
chatHost.setAiChatInput(aiChatInput)
}
return () => {
aiChatManager.setAiChatInput(null)
chatHost.setAiChatInput(null)
}
})
const showTypingIndicator = $derived(aiChatManager.loading)
const showTypingIndicator = $derived(chatHost.loading)
// The manual `@` context-picker button. Shown in SCRIPT/FLOW (workspace items +
// code blocks) and APP (datatables, frontend files). Hidden in GLOBAL — there
// `@`-context is still invoked inline by typing `@` in the input, so the button
// is redundant. NAVIGATOR/ASK/API don't take @-context at all.
const showContextPicker = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.APP
chatHost.mode === AIMode.SCRIPT || chatHost.mode === AIMode.FLOW || chatHost.mode === AIMode.APP
)
// File attachment is GLOBAL-mode only.
const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled)
const canAttachFiles = $derived(chatHost.mode === AIMode.GLOBAL && !disabled)
// Steers the OS file picker toward text + image formats (soft hint; both attach
// to the message — text files after a content sniff).
const TEXT_FILE_ACCEPT =
@@ -341,12 +350,12 @@
}
async function handleAddFiles(files: FileList | FileToAttach[]) {
const { added, rejected } = await aiChatManager.attachedFiles.addFiles(files)
const { added, rejected } = await chatHost.attachedFiles.addFiles(files)
reportAddResult(added, rejected)
}
async function addDirHandle(dir: FileSystemDirectoryHandle) {
const { added, rejected } = await aiChatManager.attachedFiles.addFolder(dir)
const { added, rejected } = await chatHost.attachedFiles.addFolder(dir)
reportAddResult(added, rejected)
}
@@ -492,9 +501,9 @@
input.value = ''
}
const autonomyAvailability = $derived({
autoAcceptEditsAvailable: aiChatManager.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: aiChatManager.autoAcceptToolConfirmationsAvailable,
planModeAvailable: aiChatManager.planModeAvailable
autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable,
autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable,
planModeAvailable: chatHost.planModeAvailable
})
const availableAutonomyModeOptions = $derived(
autonomyModeOptions.filter((option) => option.isAvailable(autonomyAvailability))
@@ -502,8 +511,8 @@
// Fall back to ask-permission when the persisted mode isn't applicable in the
// current AI mode (e.g. auto-accept edits while in a mode without edits).
const effectiveAutonomyMode = $derived(
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
availableAutonomyModeOptions.some((option) => option.mode === chatHost.autonomyMode)
? chatHost.autonomyMode
: AIAutonomyMode.DEFAULT
)
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
@@ -512,13 +521,13 @@
// 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.
const waitingForUserAction = $derived(aiChatManager.loading && !!pendingUserAction(messages))
const waitingForUserAction = $derived(chatHost.loading && !!pendingUserAction(messages))
// Gated on `loading` because a card restored from history still looks parked:
// its resolver left with the old page, so the composer must not advertise an
// answer it cannot deliver.
const pendingQuestionToolCallId = $derived.by(() => {
if (!aiChatManager.loading) {
if (!chatHost.loading) {
return undefined
}
const pending = pendingUserActionDetail(messages)
@@ -527,14 +536,14 @@
// Get app context for display when in APP mode
const appContext = $derived.by((): SelectedContext | undefined => {
if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) {
if (chatHost.mode !== AIMode.APP || !chatHost.appAiChatHelpers) {
return undefined
}
return aiChatManager.appAiChatHelpers.getSelectedContext()
return chatHost.appAiChatHelpers.getSelectedContext()
})
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
return chatHost.tools
.filter((tool) => tool.requiresConfirmation === true)
.map((tool) => ({
name: tool.def.function.name,
@@ -551,14 +560,13 @@
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
)
const showFlowPendingActionControls = $derived(
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
(chatHost.flowAiChatHelpers?.hasPendingChanges() ?? false) && !chatHost.autoAcceptEditsActive
)
const showFooterLeftControls = $derived(
!disabled &&
(showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
(chatHost.mode === AIMode.SCRIPT && hasDiff))
)
</script>
@@ -618,8 +626,8 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#each pastChats as chat (chat.id)}
<button
class="text-left flex flex-row items-center gap-2 justify-between hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md p-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent dark:disabled:hover:bg-transparent"
disabled={aiChatManager.loading || aiChatManager.sendInFlight}
title={aiChatManager.loading || aiChatManager.sendInFlight
disabled={chatHost.loading || chatHost.sendInFlight}
title={chatHost.loading || chatHost.sendInFlight
? 'Stop the current answer to switch conversation'
: undefined}
onclick={() => {
@@ -681,7 +689,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
<div class="flex-1 min-h-0 relative">
<div
class="absolute inset-0 overflow-y-scroll pt-2 scrollbar-subtle"
bind:this={scrollEl}
bind:this={scrollElement}
onscroll={onScroll}
>
<div
@@ -707,16 +715,16 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
)}
>
<ChatTypingIndicator
loading={aiChatManager.loading}
loading={chatHost.loading}
paused={waitingForUserAction}
label={aiChatManager.loadingLabel
? aiChatManager.loadingLabel
: aiChatManager.compacting
label={chatHost.loadingLabel
? chatHost.loadingLabel
: chatHost.compacting
? 'Compacting conversation'
: aiChatManager.currentReasoningActive &&
!aiChatManager.currentReply &&
!aiChatManager.currentReasoning
? (aiChatManager.reasoningHiddenIndicatorLabel ?? 'Thinking')
: chatHost.currentReasoningActive &&
!chatHost.currentReply &&
!chatHost.currentReasoning
? (chatHost.reasoningHiddenIndicatorLabel ?? 'Thinking')
: undefined}
/>
</div>
@@ -739,7 +747,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
aria-label="Scroll to latest message"
startIcon={{ icon: ArrowDown }}
on:click={() => {
aiChatManager.enableAutomaticScroll()
chatHost.enableAutomaticScroll()
scrollDown()
}}
/>
@@ -761,7 +769,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="bg-green-500 hover:bg-green-600 text-white hover:text-white"
onclick={() => {
aiChatManager.flowAiChatHelpers?.acceptAllModuleActions()
chatHost.flowAiChatHelpers?.acceptAllModuleActions()
}}
>
Accept all
@@ -773,7 +781,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
variant="default"
btnClasses="dark:opacity-50 opacity-60 hover:opacity-100"
onclick={() => {
aiChatManager.flowAiChatHelpers?.rejectAllModuleActions()
chatHost.flowAiChatHelpers?.rejectAllModuleActions()
}}
>
Reject all
@@ -783,7 +791,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
<div>
<QueuedMessageChip />
{#if aiChatManager.mode === AIMode.GLOBAL && !aiChatManager.isSessionChat}
{#if chatHost.mode === AIMode.GLOBAL && !chatHost.isSessionChat}
<!-- Standalone Jobs bar for the global side-panel chat. In /sessions the
Jobs segment lives inside the session bar (SessionChangesBar). -->
<div class="mb-1">
@@ -802,9 +810,10 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{placeholder}
{initialInstructions}
{onDraftChange}
showContext={aiChatManager.mode !== AIMode.GLOBAL}
showContext={chatHost.mode !== AIMode.GLOBAL}
{disabled}
{pendingQuestionToolCallId}
isFirstMessage={messages.length === 0}
@@ -829,7 +838,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
/>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
@@ -890,7 +899,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
linkFolder()
}
},
...(aiChatManager.mode === AIMode.GLOBAL && mcpConnections
...(chatHost.mode === AIMode.GLOBAL && mcpConnections
? [
{
displayName: 'MCP connections',
@@ -958,7 +967,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
availableAutonomyModeOptions.map((option) => ({
displayName: option.label,
selected: effectiveAutonomyMode === option.mode,
action: () => aiChatManager.setAutonomyMode(option.mode)
action: () => chatHost.setAutonomyMode(option.mode)
}))}
placement="bottom-start"
fixedHeight={false}
@@ -985,18 +994,18 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{#if effectiveAutonomyMode === AIAutonomyMode.PLAN}
<span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && chatHost.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
<AlertTriangle class="w-3 h-3 text-red-500" />
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'Bypass permissions auto-accepts edits and tool usage.'
: 'Bypass permissions auto-accepts tool usage.'}
</p>
<p class="mt-1">
{aiChatManager.autoAcceptEditsAvailable
{chatHost.autoAcceptEditsAvailable
? 'This can result in edits being applied or tools being called without user confirmation.'
: 'This can result in tools being called without user confirmation.'}
</p>
@@ -1017,7 +1026,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
{#if chatHost.mode === AIMode.SCRIPT && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
@@ -1028,22 +1037,24 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</div>
{:else}
<div class="flex flex-row gap-x-1.5 min-w-0 flex-wrap items-center">
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.mode === AIMode.GLOBAL}
<AttachedFilesBar />
{/if}
{#if !hideModeSelector}
<ChatMode />
{/if}
{#if aiChatManager.mode === AIMode.APP}
{#if chatHost.mode === AIMode.APP}
<DatatableCreationPolicy />
{/if}
<ContextUsageIndicator />
<AIChatModelSettings />
{#if aiChatManager.mode === AIMode.GLOBAL}
{#if chatHost.supportsModelSettings}
<AIChatModelSettings />
{/if}
{#if chatHost.mode === AIMode.GLOBAL}
<McpConnections bind:this={mcpConnections} />
{/if}
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if chatHost.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
{#if appContext.inspectorElement}
<div
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 text-2xs"
@@ -1090,7 +1101,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
{/if}
</div>
</div>
{#if (aiChatManager.mode === AIMode.NAVIGATOR || aiChatManager.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
{#if (chatHost.mode === AIMode.NAVIGATOR || chatHost.mode === AIMode.ASK) && suggestions.length > 0 && messages.filter((m) => m.role === 'user').length === 0 && !disabled}
<div class="px-2 mt-4">
<div class="flex flex-col gap-2">
{#each suggestions as suggestion (suggestion)}
@@ -11,7 +11,8 @@
type ContextElement
} from './context'
import { AIMode } from './AIChatManager.svelte'
import { CHAT_INPUT_PADDING, getAiChatManager } from './aiChatManagerContext'
import { CHAT_INPUT_PADDING } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { formatMention } from './mention'
import { twMerge } from 'tailwind-merge'
import { tick, untrack, type Snippet } from 'svelte'
@@ -45,7 +46,7 @@
isImageViewerOpen
} from '$lib/components/common/image/ExpandableImage.svelte'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
interface Props {
availableContext: ContextElement[]
@@ -65,15 +66,15 @@
showContext?: boolean
bottomRightSnippet?: Snippet
onKeyDown?: (e: KeyboardEvent) => void
// When provided, overrides `aiChatManager.loading` for the send/stop
// When provided, overrides `chatHost.loading` for the send/stop
// button — useful for callers driving their own request lifecycle
// (e.g. the inline ⌘K widget runs requests outside the global
// `aiChatManager.loading` flag).
// `chatHost.loading` flag).
loading?: boolean
// Called when the user clicks Stop. Defaults to `aiChatManager.cancel()`.
// Called when the user clicks Stop. Defaults to `chatHost.cancel()`.
onCancel?: () => void
// Observe the composer draft as it changes (the text is local state —
// `aiChatManager.instructions` only carries programmatic prompts). Used by
// `chatHost.instructions` only carries programmatic prompts). Used by
// sessions to persist the typed-but-unsent prompt with the session draft.
onDraftChange?: (text: string) => void
// tool_call_id of the askUserQuestion the turn is parked on, when it is. A
@@ -141,7 +142,7 @@
return placeholder
}
switch (aiChatManager.mode) {
switch (chatHost.mode) {
case AIMode.SCRIPT:
return 'Modify this script...'
case AIMode.FLOW:
@@ -209,7 +210,7 @@
/** Attach dropped/pasted image files (downscaled + bounded). GLOBAL mode only. */
export async function addImages(files: (File | Blob)[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (chatHost.mode !== AIMode.GLOBAL) return
const imageFiles = files.filter(isImageFile)
if (imageFiles.length === 0) return
// tryGetCurrentModel returns undefined instead of throwing: this runs from a
@@ -302,13 +303,13 @@
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes
)
$effect(() => {
aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
chatHost.setComposerStaged(composerKey, editingMessageIndex, stagedBytes)
})
$effect(() => () => aiChatManager.clearComposerStaged(composerKey))
$effect(() => () => chatHost.clearComposerStaged(composerKey))
/** Attach dropped/picked text files (sniffed + bounded). GLOBAL mode only. */
export async function addTextFiles(candidates: File[]) {
if (aiChatManager.mode !== AIMode.GLOBAL) return
if (chatHost.mode !== AIMode.GLOBAL) return
if (candidates.length === 0) return
const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles
if (remaining <= 0) {
@@ -340,7 +341,7 @@
// stage stands in for it, so counting both would charge those bytes twice.
let budget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
pendingFileBytes
const withinBudget: File[] = []
@@ -382,7 +383,7 @@
// from the budget — the decoded sizes replace it.
const liveBudget =
MAX_CONVERSATION_FILE_BYTES -
aiChatManager.attachmentBytesExcluding(composerKey) -
chatHost.attachmentBytesExcluding(composerKey) -
draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) -
(pendingFileBytes - reservedBytes)
const { droppedAtBudget } = draft.addFiles(reads, liveBudget)
@@ -414,9 +415,9 @@
// Modes that show the rich textarea with @-context support (workspace
// scripts, workspace flows, code blocks, DBs, etc.).
const isContextEnabledMode = $derived(
aiChatManager.mode === AIMode.SCRIPT ||
aiChatManager.mode === AIMode.FLOW ||
aiChatManager.mode === AIMode.GLOBAL
chatHost.mode === AIMode.SCRIPT ||
chatHost.mode === AIMode.FLOW ||
chatHost.mode === AIMode.GLOBAL
)
const domSelectorChips = $derived(
@@ -545,14 +546,14 @@
* the composer. The conversation is left untouched — resending creates a new
* message, unlike the bubble's edit pencil which rewinds the conversation. */
function recallLastSentMessage(): boolean {
const messages = aiChatManager.displayMessages
const messages = chatHost.displayMessages
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (message.role !== 'user' || message.synthetic) continue
// Images come from the stored turn, never the bubble: a provider
// rejection strips them from history while the bubble keeps its copy,
// and recalling that copy would re-attach the refused image.
const images = aiChatManager.storedImages(i) ?? []
const images = chatHost.storedImages(i) ?? []
// Eligibility looks at the bubble, though: the last thing the user
// actually sent is the recall boundary, so a context-only turn (GLOBAL
// allows text-free sends with chips) recalls its chips, and a turn
@@ -576,8 +577,7 @@
// count against the conversation budget — re-admit them instead of
// copying, or resending would blow past MAX_CONVERSATION_FILE_BYTES.
if (message.files?.length) {
const budget =
MAX_CONVERSATION_FILE_BYTES - aiChatManager.attachmentBytesExcluding(composerKey)
const budget = MAX_CONVERSATION_FILE_BYTES - chatHost.attachmentBytesExcluding(composerKey)
const { droppedAtBudget } = draft.addFiles(message.files, budget)
if (droppedAtBudget > 0) {
const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000)
@@ -648,10 +648,10 @@
if (
contextElement.type === 'app_datatable' &&
aiChatManager.mode === AIMode.APP &&
aiChatManager.appAiChatHelpers
chatHost.mode === AIMode.APP &&
chatHost.appAiChatHelpers
) {
const appAiChatHelpers = aiChatManager.appAiChatHelpers
const appAiChatHelpers = chatHost.appAiChatHelpers
appAiChatHelpers.addTableToWhitelist(
contextElement.datatableName,
contextElement.schemaName,
@@ -700,7 +700,7 @@
const answeredQuestionId = questionAnsweredBySend
if (
answeredQuestionId &&
aiChatManager.handleUserQuestionAnswer(answeredQuestionId, [
chatHost.handleUserQuestionAnswer(answeredQuestionId, [
expanded(chatDraft(draft.text.trim(), draft.pastes))
])
) {
@@ -708,7 +708,7 @@
contextTextareaComponent?.clearForSend()
return
}
if (aiChatManager.loading) {
if (chatHost.loading) {
// Queue the message instead of silently discarding it — it is
// auto-sent when the streaming turn completes successfully.
// Editing-while-loading keeps the old discard behavior. Paste
@@ -719,10 +719,10 @@
// chips picked at press time.
if (
editingMessageIndex === null &&
(!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0))
(!draft.isEmpty || (chatHost.mode === AIMode.GLOBAL && selectedContext.length > 0))
) {
const sent = draft.take()
aiChatManager.queueMessage(
chatHost.queueMessage(
expanded(chatDraft(sent.text, sent.pastes)),
sent.images,
[...selectedContext],
@@ -737,7 +737,7 @@
// message's original chips), so send exactly what's shown — the user may
// have added or removed chips.
const sent = draft.take()
aiChatManager.restartGeneration(
chatHost.restartGeneration(
editingMessageIndex,
sent.text,
sent.pastes,
@@ -748,7 +748,7 @@
onEditEnd()
} else {
const sent = draft.take()
aiChatManager.sendRequest({
chatHost.sendRequest({
instructions: sent.text,
pastes: sent.pastes,
images: sent.images,
@@ -977,7 +977,7 @@
<!-- The turn stays `loading` while parked on a question, but a drafted answer
is what the button should ship then — otherwise the only pointer action on
a typed answer would be Stop. Anything else keeps Stop. -->
{@const isLoading = (loading ?? aiChatManager.loading) && !questionAnsweredBySend}
{@const isLoading = (loading ?? chatHost.loading) && !questionAnsweredBySend}
{@const emptyDraft = draft.isEmpty}
<!-- A text-free GLOBAL draft with context chips is a valid turn (Enter
already sends it), so the button stays enabled there for pointer/touch
@@ -990,7 +990,7 @@
ingestionHolds > 0 ||
(emptyDraft &&
(onSendRequest !== undefined ||
aiChatManager.mode !== AIMode.GLOBAL ||
chatHost.mode !== AIMode.GLOBAL ||
selectedContext.length === 0))}
<Button
variant="subtle"
@@ -1001,7 +1001,7 @@
disabled={!isLoading && sendDisabled}
on:click={() => {
if (isLoading) {
onCancel ? onCancel() : aiChatManager.cancel()
onCancel ? onCancel() : chatHost.cancel()
} else if (!sendDisabled) {
submitRequest()
}
@@ -1087,9 +1087,9 @@
class="relative mt-1"
role="presentation"
onkeydown={(e) => {
if (e.key === 'Escape' && aiChatManager.loading) {
if (e.key === 'Escape' && chatHost.loading) {
e.preventDefault()
aiChatManager.cancel()
chatHost.cancel()
} else if (
e.key === 'ArrowUp' &&
!e.defaultPrevented &&
@@ -1111,14 +1111,14 @@
// custom-send consumers (inline widget) have their own history
// semantics.
if (
aiChatManager.queuedMessage ||
aiChatManager.queuedImages.length > 0 ||
aiChatManager.queuedFiles.length > 0 ||
(aiChatManager.queuedContext?.length ?? 0) > 0
chatHost.queuedMessage ||
chatHost.queuedImages.length > 0 ||
chatHost.queuedFiles.length > 0 ||
(chatHost.queuedContext?.length ?? 0) > 0
) {
e.preventDefault()
aiChatManager.dequeueMessage()
} else if (!aiChatManager.sendInFlight && recallLastSentMessage()) {
chatHost.dequeueMessage()
} else if (!chatHost.sendInFlight && recallLastSentMessage()) {
// History recall waits for the in-flight turn: from the moment the
// composer clears, the turn's bubble, stored images and context land
// across several awaits, so recalling now would return an incomplete
@@ -1135,10 +1135,10 @@
bind:this={contextTextareaComponent}
bind:value={draft.text}
bind:pastes={draft.pastes}
onImageFiles={aiChatManager.mode === AIMode.GLOBAL
onImageFiles={chatHost.mode === AIMode.GLOBAL
? (pasted) => void addImages(pasted)
: undefined}
onTextFiles={aiChatManager.mode === AIMode.GLOBAL
onTextFiles={chatHost.mode === AIMode.GLOBAL
? (pasted) => void addTextFiles(pasted)
: undefined}
{availableContext}
@@ -1168,7 +1168,7 @@
</div>
{/if}
</div>
{:else if aiChatManager.mode === AIMode.APP}
{:else if chatHost.mode === AIMode.APP}
{#if showContext}
{@render badgeRow()}
{/if}
@@ -1,3 +1,4 @@
import type { ChatViewHost } from './chatViewHost'
import type { ScriptLang } from '$lib/gen/types.gen'
import { JobService, type CompletedJob } from '$lib/gen'
import type { FlowOptions, ScriptOptions } from './ContextManager.svelte'
@@ -102,11 +103,7 @@ import type AIChatInput from './AIChatInput.svelte'
import { prepareApiSystemMessage, prepareApiUserMessage } from './api/core'
import { closeInterruptedToolBatch, runChatLoop, truncateToToolPairedPrefix } from './chatLoop'
import { sanitizeToolCallArguments } from './toolCallArguments'
import {
billedTokens,
normalizeContextUsage,
type ChatTokenUsage
} from './tokenUsage'
import { billedTokens, normalizeContextUsage, type ChatTokenUsage } from './tokenUsage'
import { logAiUsage } from '$lib/utils/aiUsageReporter'
import type { ReviewChangesOpts } from './monaco-adapter'
import {
@@ -407,9 +404,13 @@ function planModeHostFor(m: AIChatManager): PlanModeHost {
}
}
export class AIChatManager {
export class AIChatManager implements ChatViewHost {
contextManager = new ContextManager()
historyManager = new HistoryManager()
// The copilot owns its model choice and its own transcript, so both chat
// affordances apply here. See ChatViewHost for hosts where they don't.
supportsModelSettings = true
supportsMessageEditing = true
/** Files the user attached to the current GLOBAL-mode conversation. */
attachedFiles = new AttachedFilesStore()
/** Markdown artifacts the copilot created for the current session. */
@@ -3,7 +3,7 @@
import type { DisplayMessage, ToolDisplayMessage } from './shared'
import ContextElementBadge from './ContextElementBadge.svelte'
import AssistantMessage from './AssistantMessage.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { Button } from '$lib/components/common'
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
import AIChatInput from './AIChatInput.svelte'
@@ -14,7 +14,7 @@
import { lineCountLabel } from './pasteTokens'
import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// Per-message expand/collapse state for paste chips shown in the bubble.
let expandedPastes = $state<Set<number>>(new Set())
@@ -50,7 +50,12 @@
let editContext = $state<ContextElement[]>([])
function editMessage() {
if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) {
if (
!chatHost.supportsMessageEditing ||
message.role !== 'user' ||
editingMessageIndex !== null ||
chatHost.loading
) {
return
}
editContext = [...(message.contextElements ?? [])]
@@ -67,7 +72,9 @@
message.role === 'tool' && 'mb-1',
message.role === 'user' && messageIndex > 0 && 'mt-4 mb-6',
isLast && '!mb-12',
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
message.role !== 'user' || !chatHost.supportsMessageEditing
? 'cursor-default'
: 'cursor-pointer'
)}
role="button"
tabindex="0"
@@ -104,7 +111,7 @@
bind:selectedContext={editContext}
initialInstructions={message.content}
initialPastes={message.pastes}
initialImages={aiChatManager.storedImages(messageIndex)}
initialImages={chatHost.storedImages(messageIndex)}
initialFiles={message.files}
{editingMessageIndex}
onClickOutside={() => (editingMessageIndex = null)}
@@ -173,9 +180,9 @@
on:click={() => {
if (message.snapshot) {
if (message.snapshot.type === 'flow') {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
} else if (message.snapshot.type === 'app') {
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
chatHost.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
}
}
}}
@@ -194,7 +201,7 @@
variant="default"
title="Retry generation"
startIcon={{ icon: RefreshCwIcon }}
onclick={() => aiChatManager.retryRequest(messageIndex)}
onclick={() => chatHost.retryRequest(messageIndex)}
>
Retry
</Button>
@@ -4,7 +4,7 @@
import { CircleHelp, ArrowUp, Plus, Square, SquareCheck } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import type { UserQuestionDisplay } from './shared'
// Sessions inject a per-pane `AIChatManager` via context; outside of
@@ -12,7 +12,7 @@
// this, answers clicked inside a session would dispatch to the singleton's
// pending callbacks map (which doesn't have the session manager's question
// callback), and the AI loop would stall.
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
interface Props {
toolCallId: string
@@ -93,14 +93,14 @@
}
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [choice])
chatHost.handleUserQuestionAnswer(toolCallId, [choice])
}
function submitPicked() {
if (!multiSelect || picked.size === 0) {
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [...picked])
chatHost.handleUserQuestionAnswer(toolCallId, [...picked])
}
function submitCustomAnswer() {
@@ -119,7 +119,7 @@
return
}
aiChatManager.handleUserQuestionAnswer(toolCallId, [answer])
chatHost.handleUserQuestionAnswer(toolCallId, [answer])
}
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
@@ -1,16 +1,16 @@
<script lang="ts">
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
import { getKnownModelContextWindow, getModelContextWindow } from '../modelConfig'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
import { AIMode } from './AIChatManager.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import { formatTokenCount } from './tokenUsage'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
// The `/compact` slash command is only wired up in session-chat GLOBAL mode,
// so only advertise it where it actually works.
let canCompact = $derived(aiChatManager.isSessionChat && aiChatManager.mode === AIMode.GLOBAL)
let canCompact = $derived(chatHost.isSessionChat && chatHost.mode === AIMode.GLOBAL)
let providerModel = $derived(
$copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0]
@@ -27,10 +27,10 @@
// The same number the compaction trigger uses: the provider's report when
// one describes the current history (one turn stale by nature), otherwise
// a live chars/4 estimate of the stored context.
let usedTokens = $derived(Math.round(aiChatManager.contextTokens))
let usedTokens = $derived(Math.round(chatHost.contextTokens))
// Always surface usage once a conversation has started, at any fill level, so
// the user can watch context grow toward the compaction threshold.
let visible = $derived(usedTokens > 0 && aiChatManager.messages.length > 0)
let visible = $derived(usedTokens > 0 && chatHost.messages.length > 0)
// Compaction triggers at 80% of the window (COMPACTION_TRIGGER_RATIO); the
// gauge fills toward that point and turns red once it is reached.
@@ -3,7 +3,7 @@
import { FileText, X } from 'lucide-svelte'
import ContextElementBadge from './ContextElementBadge.svelte'
import { contextElementKey } from './context'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
// The single message typed while a turn was streaming, waiting to be
// auto-sent when the turn finishes. Rendered above the whole input stack
@@ -11,7 +11,7 @@
// conversation". Pressing Enter again appends another line to it; clicking
// the chip body (its X, or ArrowUp in the empty input) removes it and
// restores its content into the input so nothing is lost.
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
</script>
<!-- Attachment-only and context-only queues have empty text; without their
@@ -20,23 +20,23 @@
here only for context-ONLY queues: text queues pin the same chips, but
those stay visible in the composer, and repeating them would read as two
selections. -->
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0 || (aiChatManager.queuedContext?.length ?? 0) > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0 || (chatHost.queuedContext?.length ?? 0) > 0}
<!-- The body and the X are sibling buttons for the same action (an X inside a
clickable chip would be a nested interactive control, invalid ARIA). -->
<div
class="mb-1 flex flex-row items-start gap-1 rounded-md bg-surface-input px-3 py-2 opacity-60 hover:opacity-100"
>
{#if aiChatManager.queuedMessage || aiChatManager.queuedImages.length > 0 || aiChatManager.queuedFiles.length > 0}
{#if chatHost.queuedMessage || chatHost.queuedImages.length > 0 || chatHost.queuedFiles.length > 0}
<button
type="button"
class="min-w-0 grow text-left cursor-pointer"
title={aiChatManager.queuedMessage}
title={chatHost.queuedMessage}
aria-label="Remove queued message and put it back in the input"
onclick={() => aiChatManager.dequeueMessage()}
onclick={() => chatHost.dequeueMessage()}
>
{#if aiChatManager.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedImages as image, i (i)}
{#if chatHost.queuedImages.length > 0}
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedImages as image, i (i)}
<img
src={image.dataUrl}
alt={image.name ?? 'queued image'}
@@ -45,9 +45,9 @@
{/each}
</div>
{/if}
{#if aiChatManager.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {aiChatManager.queuedMessage ? 'mb-1' : ''}">
{#each aiChatManager.queuedFiles as file, i (i)}
{#if chatHost.queuedFiles.length > 0}
<div class="flex flex-row flex-wrap gap-1 {chatHost.queuedMessage ? 'mb-1' : ''}">
{#each chatHost.queuedFiles as file, i (i)}
<span
class="flex flex-row items-center gap-1 px-1.5 rounded border border-border-light text-2xs text-secondary max-w-36"
title={file.name}
@@ -58,20 +58,20 @@
{/each}
</div>
{/if}
{#if aiChatManager.queuedMessage}
{#if chatHost.queuedMessage}
<p class="text-xs text-secondary whitespace-pre-wrap line-clamp-2">
{aiChatManager.queuedMessage}
{chatHost.queuedMessage}
</p>
{/if}
</button>
{:else if aiChatManager.queuedContext?.length}
{:else if chatHost.queuedContext?.length}
<!-- Context badges are interactive themselves (popover preview), so a
context-only queue gets a plain row instead of the clickable body —
nesting the badges in it would be invalid ARIA and a badge click
would dequeue out from under the opening popover. The X (and
ArrowUp in the empty input) still restores the queue. -->
<div class="min-w-0 grow flex flex-row flex-wrap gap-1">
{#each aiChatManager.queuedContext as element (contextElementKey(element))}
{#each chatHost.queuedContext as element (contextElementKey(element))}
<ContextElementBadge contextElement={element} compact />
{/each}
</div>
@@ -82,7 +82,7 @@
iconOnly
title="Remove queued message and put it back in the input"
startIcon={{ icon: X }}
on:click={() => aiChatManager.dequeueMessage()}
on:click={() => chatHost.dequeueMessage()}
/>
</div>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { twMerge } from 'tailwind-merge'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
interface Props {
toolCallId: string | undefined
@@ -25,11 +25,11 @@
class: className
}: Props = $props()
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
function respond(confirmed: boolean) {
if (toolCallId) {
aiChatManager.handleToolConfirmation(toolCallId, confirmed)
chatHost.handleToolConfirmation(toolCallId, confirmed)
}
}
</script>
@@ -21,9 +21,9 @@
} from './planMode'
import { Button } from '$lib/components/common'
import { markdownProse } from '$lib/components/markdownProse'
import { getAiChatManager } from './aiChatManagerContext'
import { getChatViewHost } from './chatViewHost'
const aiChatManager = getAiChatManager()
const chatHost = getChatViewHost()
import { isActiveUserQuestion, type ToolDisplayMessage } from './shared'
import ChatCollapsibleCard from './ChatCollapsibleCard.svelte'
import { twMerge } from 'tailwind-merge'
@@ -61,7 +61,7 @@
const planLabel = $derived((planState && planCopy?.[planState]) ?? '')
const planDoc = $derived(
message.planArtifactId
? aiChatManager.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
? chatHost.artifacts.artifacts.find((a) => a.id === message.planArtifactId)
: undefined
)
// The version this card wrote, not the document's current one, since later proposals move it on.
@@ -187,7 +187,7 @@
title="Open this plan in the side panel: {planDoc.name}"
startIcon={{ icon: FileText, classes: PLAN_MODE_TEXT_COLOR }}
endIcon={{ icon: PanelRight }}
on:click={() => aiChatManager.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
on:click={() => chatHost.openArtifact?.(planDoc.id, planDoc.name, planCardVersion)}
>
<span class="font-main">Plan</span>
</Button>
@@ -0,0 +1,122 @@
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[]
}
/**
* 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.
*
* `mode` is what gates the copilot-only chrome context picker, file
* attachments, MCP, autonomy, suggestions. A host that leaves it undefined gets
* a bare transcript + composer, which is what a non-copilot host wants.
*/
export interface ChatViewHost {
// Transcript
displayMessages: DisplayMessage[]
/** API-level messages. Only the count is read (context usage visibility). */
messages: readonly unknown[]
contextTokens: number
loading: 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
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
// 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
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()
}