mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
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:
co-authored by
Claude Opus 5
parent
aa4a6ffd66
commit
4ac845dde6
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user