diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index aefa123713..a267e71e8c 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -39,6 +39,7 @@ import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' + import AttachedFilesBar from './files/AttachedFilesBar.svelte' import QueuedMessageChip from './QueuedMessageChip.svelte' import JobsSegment from './JobsSegment.svelte' import { getModifierKey } from '$lib/utils' @@ -272,8 +273,8 @@ // File attachment is GLOBAL-mode only. const canAttachFiles = $derived(aiChatManager.mode === AIMode.GLOBAL && !disabled) - // Steers the OS file picker toward text + image formats (soft hint; images attach to - // the message, other files link as text context after a content sniff). + // 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 = 'image/*,text/*,.txt,.csv,.tsv,.json,.jsonl,.ndjson,.md,.markdown,.log,.yaml,.yml,.toml,.ini,.cfg,.conf,.env,.xml,.html,.htm,.css,.js,.mjs,.cjs,.ts,.tsx,.jsx,.py,.rb,.rs,.go,.java,.kt,.c,.h,.cpp,.cc,.cs,.php,.sh,.bash,.zsh,.sql,.svelte,.vue,.dockerfile' let fileInputEl = $state(null) @@ -361,16 +362,30 @@ e.preventDefault() const dt = e.dataTransfer if (!dt) return - // Images attach to the message; other files link as text context. Images are - // reserved from dt.files BEFORE any await (a send mid-ingestion would land - // them on the next message), and dt.files is the only place a disk-less drag - // exists — a cross-tab image resolves every getAsFileSystemHandle() to null. + // Images and loose text files attach to the message; folders link as session + // assets. Images are reserved from dt.files BEFORE any await (a send + // mid-ingestion would land them on the next message), and dt.files is the + // only place a disk-less drag exists — a cross-tab image resolves every + // getAsFileSystemHandle() to null. const flatFiles = Array.from(dt.files ?? []) const topLevelImages = flatFiles.filter(isImageFile) const imageWork: Promise[] = [] if (topLevelImages.length > 0) { imageWork.push(aiChatInput?.addImages(topLevelImages) ?? Promise.resolve()) } + // Text-file routing must await handle/entry resolution before it can call + // addTextFiles — hold sending across that window (taken BEFORE the first + // await) or a send mid-resolution would land the drop on the next message. + const releaseSendHold = aiChatInput?.holdSendForIngestion() + try { + await routeDroppedTextAndFolders(dt, flatFiles) + } finally { + releaseSendHold?.() + } + await Promise.all(imageWork) + } + + async function routeDroppedTextAndFolders(dt: DataTransfer, flatFiles: File[]) { if (canUseFsAccess) { // getAsFileSystemHandle calls are kicked off synchronously inside this call. const handles = await handlesFromDataTransfer(dt) @@ -381,9 +396,9 @@ handles.length === 0 ? flatFiles : await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile())) - // Files are always snapshotted (handle discarded). + // Loose text files attach to the message, like images. const textFiles = looseFiles.filter((f) => !isImageFile(f)) - if (textFiles.length > 0) await handleAddFiles(textFiles) + if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles) // Folders link as a live handle. for (const h of handles.filter(isDirectoryHandle)) { await addDirHandle(h) @@ -395,19 +410,25 @@ // (no entry API), fall back to the flat dt.files. const entries = await readDroppedEntries(Array.from(dt.items ?? [])) const source: FileToAttach[] = entries.length > 0 ? entries : flatFiles - // Only top-level images attach to the message, and those were already - // reserved from dt.files before the walk — drop them here so they aren't - // re-reported as skipped non-text. Folder-nested images are deliberately - // NOT attached (the FSA path never extracts folder contents either); they - // ride the text ingestion and are summarized as skipped. - const textEntries = source.filter((entry) => { + // Top-level files attach to the message (images were already reserved + // from dt.files before the walk). Folder children keep riding the + // session store as a snapshot — including nested images, which are + // deliberately NOT attached (the FSA path never extracts folder + // contents either); they are summarized as skipped there. + const topLevelText: File[] = [] + const folderEntries: FileToAttach[] = [] + for (const entry of source) { const file = entry instanceof File ? entry : entry.file - const nested = !(entry instanceof File) && entry.path?.includes('/') - return !isImageFile(file) || !!nested - }) - if (textEntries.length > 0) await handleAddFiles(textEntries) + const nested = !(entry instanceof File) && !!entry.path?.includes('/') + if (nested) { + folderEntries.push(entry) + } else if (!isImageFile(file)) { + topLevelText.push(file) + } + } + if (folderEntries.length > 0) await handleAddFiles(folderEntries) + if (topLevelText.length > 0) await aiChatInput?.addTextFiles(topLevelText) } - await Promise.all(imageWork) } async function onFileInputChange(e: Event) { @@ -418,7 +439,7 @@ const textFiles = picked.filter((f) => !isImageFile(f)) // Reserved before the text work is awaited — see onPanelDrop. const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined - if (textFiles.length > 0) await handleAddFiles(textFiles) + if (textFiles.length > 0) await aiChatInput?.addTextFiles(textFiles) await imageWork } input.value = '' // allow re-selecting the same file @@ -754,10 +775,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/if} - + mention deselects). Hence showContext={false} below. Session-scoped + assets (attached files/folders) render in the footer row instead. --> {#if inputPreface} {@render inputPreface()} {/if} @@ -863,12 +885,12 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->

Attach files or link a folder

- Text files stay in your browser, and a folder is linked live from disk. - The assistant lists, searches, and reads them on demand, so their contents - are sent only when it reads one. + Files and images attach to your next message. Images are seen directly; + file contents stay in your browser and are read on demand.

- Images are sent with your next message, so the assistant can see them. + A linked folder is a session-wide resource: the assistant lists, searches, + and reads its files whenever it needs them.

{/snippet} @@ -876,7 +898,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {/snippet} + `accept` only steers the picker; the content sniff at attach is authoritative. --> {:else}
+ {#if aiChatManager.mode === AIMode.GLOBAL} + + {/if} {#if !hideModeSelector} {/if} diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 427856d291..336246b368 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -2,10 +2,10 @@ import AppAvailableContextList from './AppAvailableContextList.svelte' import ContextElementBadge from './ContextElementBadge.svelte' import ContextTextarea from './ContextTextarea.svelte' - import AttachedFilesBar from './files/AttachedFilesBar.svelte' import autosize from '$lib/autosize' import { contextElementKey, + createAttachedFileContextElement, isSameContextElement, type AppDomSelectorElement, type ContextElement @@ -31,6 +31,16 @@ } from './imageUtils' import { modelSupportsVision } from '../modelConfig' import { tryGetCurrentModel } from '$lib/aiStore' + import { createLongHash } from '$lib/editorLangUtils' + import { + fileToAttachedTextFile, + MAX_ATTACHED_FILES, + MAX_CONVERSATION_FILE_BYTES, + MAX_TEXT_FILE_BYTES, + textByteLength, + type AttachedTextFile + } from './textFileUtils' + import { MessageDraft } from './messageDraft.svelte' import ExpandableImage, { isImageViewerOpen } from '$lib/components/common/image/ExpandableImage.svelte' @@ -46,6 +56,7 @@ initialInstructions?: string initialPastes?: PasteAttachment[] initialImages?: AttachedImage[] + initialFiles?: AttachedTextFile[] editingMessageIndex?: number | null onEditEnd?: () => void className?: string @@ -76,6 +87,7 @@ initialInstructions = '', initialPastes = undefined, initialImages = undefined, + initialFiles = undefined, editingMessageIndex = null, onEditEnd = () => {}, className = '', @@ -142,16 +154,22 @@ let contextTextareaComponent: ContextTextarea | undefined = $state() let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state() - let instructions = $state(untrack(() => initialInstructions)) + // The four lanes that ship with the next send — text, collapsed big-paste + // blobs, per-message images, per-message text files — owned by one draft so + // every aggregation applies the draft rules. The composer keeps only the + // async in-flight accounting (pending counters, byte reservations). + const draft = new MessageDraft( + untrack(() => ({ + text: initialInstructions, + pastes: initialPastes ?? [], + images: initialImages ?? [], + files: initialFiles ?? [] + })) + ) $effect(() => { - const text = instructions + const text = draft.text untrack(() => onDraftChange?.(text)) }) - // Collapsed big-paste blobs referenced by tokens in `instructions`. - let pastes = $state(untrack(() => initialPastes ?? [])) - // Per-message image attachments (drag/drop/paste), GLOBAL mode only. One-shot: - // they attach to the next send and clear, unlike the persistent attached-files store. - let images = $state(untrack(() => initialImages ?? [])) // Images being decoded right now. Holds off sending so a message can never go // out without an attachment the user already dropped, and reserves cap slots // against a concurrent drop. @@ -171,10 +189,10 @@ sendUserToast(`${model.model} can't read images. Switch to a vision model first.`, true) return } - // Count decodes already in flight: two drops that both read `images.length` + // Count decodes already in flight: two drops that both read the image count // before either resolves would each claim the same free slots and overshoot // the cap. - const remaining = MAX_ATTACHED_IMAGES - images.length - pendingImages + const remaining = MAX_ATTACHED_IMAGES - draft.images.length - pendingImages if (remaining <= 0) { sendUserToast(`You can attach up to ${MAX_ATTACHED_IMAGES} images.`, true) return @@ -210,7 +228,7 @@ failed++ } } - if (added.length > 0) images = [...images, ...added] + if (added.length > 0) draft.addImages(added) if (failed > 0) sendUserToast(`Could not attach ${failed} image(s).`, true) } finally { pendingImages -= batch.length @@ -218,7 +236,140 @@ } function removeImage(index: number) { - images = images.filter((_, i) => i !== index) + draft.images = draft.images.filter((_, i) => i !== index) + } + + // Files being read right now — same send-hold/slot-reservation role as pendingImages. + let pendingFiles = $state(0) + // Drop routing resolves file-system handles/entries asynchronously before it + // can call addTextFiles/addImages; a send during that window would land the + // dropped files on the NEXT message. Holds block sending (no slot or chip + // impact) until the drop handler finishes routing. + let ingestionHolds = $state(0) + export function holdSendForIngestion(): () => void { + ingestionHolds += 1 + let released = false + return () => { + if (!released) { + released = true + ingestionHolds -= 1 + } + } + } + // Bytes those in-flight reads have claimed against the conversation budget: + // two overlapping drops that both read the budget before either lands would + // otherwise each spend the same remaining allowance. + let pendingFileBytes = $state(0) + + // Publish this composer's staged bytes (committed attachments + in-flight + // reads) to the manager so a concurrently-mounted composer — the edit box + // while editing an earlier message — sees them in its own budget check and + // the two can't each spend the whole conversation allowance. + const composerKey = untrack(() => createLongHash()) + let stagedBytes = $derived( + draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) + pendingFileBytes + ) + $effect(() => { + aiChatManager.setComposerStaged(composerKey, editingMessageIndex, stagedBytes) + }) + $effect(() => () => aiChatManager.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 (candidates.length === 0) return + const remaining = MAX_ATTACHED_FILES - draft.files.length - pendingFiles + if (remaining <= 0) { + sendUserToast(`You can attach up to ${MAX_ATTACHED_FILES} files.`, true) + return + } + const oversized = candidates.filter((f) => f.size > MAX_TEXT_FILE_BYTES) + if (oversized.length > 0) { + const mb = Math.round(MAX_TEXT_FILE_BYTES / 1_000_000) + sendUserToast( + `${oversized.length} file(s) over ${mb}MB were skipped — link their folder to read them on demand.`, + true + ) + } + const usable = candidates.filter((f) => f.size <= MAX_TEXT_FILE_BYTES) + if (usable.length === 0) return + let batch = usable.slice(0, remaining) + if (batch.length < usable.length) { + sendUserToast( + `You can attach up to ${MAX_ATTACHED_FILES} files; ${usable.length - batch.length} were skipped.`, + true + ) + } + // Conversation-level byte budget: transcript + queue + every live + // composer's stage (this one and, mid-edit, the other) + this composer's + // own pending reads. File content is persisted with every history save, so + // an unbounded total would grow the chat record without limit. The + // transcript sum skips any message a composer is editing — that composer's + // stage stands in for it, so counting both would charge those bytes twice. + let budget = + MAX_CONVERSATION_FILE_BYTES - + aiChatManager.attachmentBytesExcluding(composerKey) - + draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) - + pendingFileBytes + const withinBudget: File[] = [] + for (const f of batch) { + if (f.size <= budget) { + withinBudget.push(f) + budget -= f.size + } + } + if (withinBudget.length < batch.length) { + const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000) + sendUserToast( + `${batch.length - withinBudget.length} file(s) skipped — this conversation reached its ${mb}MB attachment budget. Link a folder to read larger sets on demand.`, + true + ) + } + batch = withinBudget + if (batch.length === 0) return + pendingFiles += batch.length + const reservedBytes = batch.reduce((sum, f) => sum + f.size, 0) + pendingFileBytes += reservedBytes + try { + const reads: { name: string; content: string }[] = [] + let skipped = 0 + for (const file of batch) { + try { + const attached = await fileToAttachedTextFile(file) + if (attached) reads.push(attached) + else skipped++ + } catch { + skipped++ + } + } + // Commit through the draft in one synchronous step — fold (dedupe, + // courtesy rename) and decoded-byte admission both run against the live + // list, so another batch landing between this one's file reads can't be + // missed, and malformed input that inflates on decode can't slip past the + // raw-size admission above. This batch's own raw reservation is excluded + // from the budget — the decoded sizes replace it. + const liveBudget = + MAX_CONVERSATION_FILE_BYTES - + aiChatManager.attachmentBytesExcluding(composerKey) - + draft.files.reduce((sum, f) => sum + textByteLength(f.content), 0) - + (pendingFileBytes - reservedBytes) + const { droppedAtBudget } = draft.addFiles(reads, liveBudget) + if (droppedAtBudget > 0) { + const mb = Math.round(MAX_CONVERSATION_FILE_BYTES / 1_000_000) + sendUserToast( + `${droppedAtBudget} file(s) skipped — this conversation reached its ${mb}MB attachment budget. Link a folder to read larger sets on demand.`, + true + ) + } + if (skipped > 0) sendUserToast(`Skipped ${skipped} file(s) (non-text).`, true) + } finally { + pendingFiles -= batch.length + pendingFileBytes -= reservedBytes + } + } + + function removeFile(index: number) { + draft.files = draft.files.filter((_, i) => i !== index) } // App mode @ mention state @@ -250,9 +401,9 @@ * leave duplicate tokens. */ export function insertMention(title: string) { const target = `@${title}` - if (instructions.split(/\s+/).includes(target)) return - const sep = instructions.length === 0 || /\s$/.test(instructions) ? '' : ' ' - instructions = `${instructions}${sep}${target} ` + if (draft.text.split(/\s+/).includes(target)) return + const sep = draft.text.length === 0 || /\s$/.test(draft.text) ? '' : ' ' + draft.text = `${draft.text}${sep}${target} ` } /** Strip every `@title` token from the textarea — used when the user @@ -268,7 +419,7 @@ contextTextareaComponent?.unsyncMention(title) const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const re = new RegExp(`(^|\\s)@${escaped}(\\s|$)`, 'g') - instructions = instructions.replace(re, (_m, lead, trail) => { + draft.text = draft.text.replace(re, (_m, lead, trail) => { // Boundary on at least one side → drop the mention entirely. if (!lead || !trail) return '' // Middle of text: keep ONE of the bracketing whitespace chars so @@ -296,12 +447,23 @@ export function restoreInstructions( value: string, restoredPastes: PasteAttachment[] = [], - restoredImages: AttachedImage[] = [] + restoredImages: AttachedImage[] = [], + restoredFiles: AttachedTextFile[] = [] ): boolean { - if (instructions.trim() || images.length > 0 || pendingImages > 0) return false - instructions = value - pastes = restoredPastes - images = restoredImages + // Attachments still decoding/reading (or mid-drop-routing) count as + // occupancy too — they belong to a draft the user started even though + // their lane is still empty. + if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) return false + if ( + !draft.replaceIfEmpty({ + text: value, + pastes: restoredPastes, + images: restoredImages, + files: restoredFiles + }) + ) { + return false + } focusInput() return true } @@ -311,24 +473,30 @@ * the user typed is lost. Restored images join whatever is already * attached, up to the cap — dropping them would lose the attachment * silently, which is the whole reason the queue carries them. */ - export function prependText(text: string, restoredImages: AttachedImage[] = []): boolean { - // Whether the restored text landed on top of a draft the user was already - // writing: both instructions now share one composer, so the caller must keep - // both their contexts rather than replacing one with the other. - const mergedIntoDraft = !!text && !!instructions.trim() - // An image-only restore has empty text; prepending it would only add blank lines. - if (text) { - instructions = instructions.trim() ? `${text}\n\n${instructions}` : text + export function prependText( + text: string, + restoredImages: AttachedImage[] = [], + restoredFiles: AttachedTextFile[] = [] + ): boolean { + // mergedIntoDraft: the restored text landed on top of a draft the user was + // already writing — both instructions now share one composer, so the caller + // must keep both their contexts rather than replacing one with the other. + const { mergedIntoDraft, droppedImages, droppedFiles } = draft.prepend({ + text, + images: restoredImages, + files: restoredFiles + }) + if (droppedImages > 0) { + sendUserToast( + `You can attach up to ${MAX_ATTACHED_IMAGES} images; ${droppedImages} restored image(s) were dropped.`, + true + ) } - if (restoredImages.length > 0) { - const merged = [...images, ...restoredImages] - if (merged.length > MAX_ATTACHED_IMAGES) { - sendUserToast( - `You can attach up to ${MAX_ATTACHED_IMAGES} images; ${merged.length - MAX_ATTACHED_IMAGES} restored image(s) were dropped.`, - true - ) - } - images = merged.slice(0, MAX_ATTACHED_IMAGES) + if (droppedFiles > 0) { + sendUserToast( + `You can attach up to ${MAX_ATTACHED_FILES} files; ${droppedFiles} restored file(s) were dropped.`, + true + ) } focusInput() return mergedIntoDraft @@ -336,8 +504,8 @@ /** Insert a plain @filename mention for an attached file (used by the @ menu Files category). */ export function insertFileMention(name: string) { - const sep = instructions.length === 0 || instructions.endsWith(' ') ? '' : ' ' - instructions = `${instructions}${sep}${formatMention(name)} ` + const sep = draft.text.length === 0 || draft.text.endsWith(' ') ? '' : ' ' + draft.text = `${draft.text}${sep}${formatMention(name)} ` focusInput() } @@ -429,8 +597,8 @@ function sendRequest() { // The send button is disabled while decoding, but Enter reaches here directly. - // Sending now would drop the in-flight images onto the following message. - if (pendingImages > 0) { + // Sending now would drop the in-flight attachments onto the following message. + if (pendingImages > 0 || pendingFiles > 0 || ingestionHolds > 0) { return } if (aiChatManager.loading) { @@ -444,17 +612,16 @@ // chips picked at press time. if ( editingMessageIndex === null && - (instructions.trim() || - images.length > 0 || - (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0)) + (!draft.isEmpty || (aiChatManager.mode === AIMode.GLOBAL && selectedContext.length > 0)) ) { - aiChatManager.queueMessage(expanded(chatDraft(instructions, pastes)), images, [ - ...selectedContext - ]) + const sent = draft.take() + aiChatManager.queueMessage( + expanded(chatDraft(sent.text, sent.pastes)), + sent.images, + [...selectedContext], + sent.files + ) contextTextareaComponent?.clearForSend() - instructions = '' - pastes = [] - images = [] } return } @@ -462,25 +629,30 @@ // In edit mode selectedContext is the edit box's own copy (seeded from the // message's original chips), so send exactly what's shown — the user may // have added or removed chips. + const sent = draft.take() aiChatManager.restartGeneration( editingMessageIndex, - instructions, - pastes, - images, - selectedContext + sent.text, + sent.pastes, + sent.images, + selectedContext, + sent.files ) onEditEnd() } else { - aiChatManager.sendRequest({ instructions, pastes, images }) + const sent = draft.take() + aiChatManager.sendRequest({ + instructions: sent.text, + pastes: sent.pastes, + images: sent.images, + files: sent.files + }) // clearForSend() pre-zaps the textarea's mention-sync so the wipe // doesn't drop `selectedContext` before `AIChatManager.beforeSend` // snapshots it. Only mounted in SCRIPT/FLOW/GLOBAL — APP and the - // fallback textarea still rely on the plain `instructions = ''` - // reset (no `@`-mention state to coordinate). + // fallback textarea still rely on the draft reset alone (no + // `@`-mention state to coordinate). contextTextareaComponent?.clearForSend() - instructions = '' - pastes = [] - images = [] } } @@ -489,7 +661,7 @@ // for the conversation bubble and expands them for the LLM inside the manager. function submitRequest() { if (onSendRequest) { - onSendRequest(expanded(chatDraft(instructions, pastes))) + onSendRequest(expanded(chatDraft(draft.text, draft.pastes))) } else { sendRequest() } @@ -661,7 +833,7 @@ } function handleAppInput(_e: Event) { - const words = instructions.split(/\s+/) + const words = draft.text.split(/\s+/) const lastWord = words[words.length - 1] if ( @@ -680,9 +852,9 @@ function handleAppContextSelection(contextElement: ContextElement) { void addContextToSelection(contextElement) // Update instructions with the selected context title - const index = instructions.lastIndexOf('@') + const index = draft.text.lastIndexOf('@') if (index !== -1) { - instructions = instructions.substring(0, index) + `@${contextElement.title}` + draft.text = draft.text.substring(0, index) + `@${contextElement.title}` } showAppContextTooltip = false } @@ -696,7 +868,7 @@ {#snippet sendStopButton()} {@const isLoading = loading ?? aiChatManager.loading} - {@const emptyDraft = instructions.trim().length === 0 && images.length === 0} + {@const emptyDraft = draft.isEmpty} +{#snippet badgeRow()} + {@const contextChips = showContext ? selectedContext : domSelectorChips} + {#if contextChips.length > 0 || draft.files.length > 0 || pendingFiles > 0}
- {#each selectedContext as element (contextKey(element))} + {#each contextChips as element (contextKey(element))} { selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element)) - removeMention(element.title) + if (showContext) removeMention(element.title) }} /> {/each} -
- {/if} -{/snippet} - - -{#snippet domSelectorChipRow()} - {#if domSelectorChips.length > 0} -
- {#each domSelectorChips as element (contextKey(element))} + {#each draft.files as file, i (i)} { - selectedContext = selectedContext?.filter((c) => !isSameContextElement(c, element)) - }} + onDelete={() => removeFile(i)} /> {/each} + {#each { length: pendingFiles } as _, i (i)} +
+ +
+ {/each}
{/if} {/snippet} {#snippet imageChipsRow()} - {#if images.length > 0 || pendingImages > 0} -
- {#each images as image, i (i)} + {#if draft.images.length > 0 || pendingImages > 0} +
+ {#each draft.images as image, i (i)}
@@ -811,10 +987,13 @@
void addImages(files) + ? (pasted) => void addImages(pasted) + : undefined} + onTextFiles={aiChatManager.mode === AIMode.GLOBAL + ? (pasted) => void addTextFiles(pasted) : undefined} {availableContext} {selectedContext} @@ -833,16 +1012,7 @@ {onKeyDown} > {#snippet leading()} - {#if aiChatManager.mode === AIMode.GLOBAL} -
- -
- {/if} - {#if showContext} - {@render contextPickerRow()} - {:else} - {@render domSelectorChipRow()} - {/if} + {@render badgeRow()} {@render imageChipsRow()} {/snippet}
@@ -854,12 +1024,12 @@
{:else if aiChatManager.mode === AIMode.APP} {#if showContext} - {@render contextPickerRow()} + {@render badgeRow()} {/if}