fix: keep ai composer responsive after image paste

This commit is contained in:
lucaronin
2026-04-24 13:52:58 +02:00
parent f2c83be4b3
commit daa1e3e985
10 changed files with 144 additions and 2 deletions

View File

@@ -1421,6 +1421,7 @@ function App() {
inspectorWidth={layout.inspectorWidth}
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
onUnsupportedAiPaste={setToastMessage}
onInspectorResize={layout.handleInspectorResize}
inspectorEntry={activeTab?.entry ?? null}
inspectorContent={activeTab?.content ?? null}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent, act } from '@testing-library/react'
import { AiPanel } from './AiPanel'
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
import type { VaultEntry } from '../types'
import { queueAiPrompt } from '../utils/aiPromptBridge'
@@ -245,4 +246,30 @@ describe('AiPanel', () => {
])
expect(screen.getByTestId('agent-send')).toBeDisabled()
})
it('surfaces an unsupported image paste notice without locking the composer', () => {
const onUnsupportedAiPaste = vi.fn()
const entry = makeEntry({ title: 'My Note' })
render(
<AiPanel
onClose={vi.fn()}
vaultPath="/tmp/vault"
activeEntry={entry}
entries={[entry]}
onUnsupportedAiPaste={onUnsupportedAiPaste}
/>,
)
fireEvent.paste(screen.getByTestId('agent-input'), {
clipboardData: {
getData: vi.fn(() => ''),
files: [new File(['image'], 'paste.png', { type: 'image/png' })],
items: [{ kind: 'file', type: 'image/png' }],
},
})
expect(onUnsupportedAiPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE)
expect(screen.getByTestId('agent-input').textContent).not.toContain('paste.png')
})
})

View File

@@ -17,6 +17,7 @@ export type { AiAgentMessage } from '../hooks/useCliAiAgent'
interface AiPanelProps {
onClose: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReady?: boolean
onFileCreated?: (relativePath: string) => void
@@ -36,6 +37,7 @@ interface AiPanelViewProps {
controller: AiPanelController
onClose: () => void
onOpenNote?: (path: string) => void
onUnsupportedAiPaste?: (message: string) => void
defaultAiAgent?: AiAgentId
defaultAiAgentReady?: boolean
activeEntry?: VaultEntry | null
@@ -46,6 +48,7 @@ export function AiPanelView({
controller,
onClose,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
activeEntry,
@@ -125,6 +128,7 @@ export function AiPanelView({
legacyCopy={useLegacyAiExperience}
onChange={setInput}
onSend={handleSend}
onUnsupportedAiPaste={onUnsupportedAiPaste}
/>
</aside>
)
@@ -133,6 +137,7 @@ export function AiPanelView({
export function AiPanel({
onClose,
onOpenNote,
onUnsupportedAiPaste,
defaultAiAgent: providedDefaultAiAgent,
defaultAiAgentReady: providedDefaultAiAgentReady,
onFileCreated,
@@ -167,6 +172,7 @@ export function AiPanel({
controller={controller}
onClose={onClose}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={providedDefaultAiAgent}
defaultAiAgentReady={providedDefaultAiAgentReady}
activeEntry={activeEntry}

View File

@@ -42,6 +42,7 @@ interface AiPanelComposerProps {
legacyCopy: boolean
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
onUnsupportedAiPaste?: (message: string) => void
}
function getComposerPlaceholder(
@@ -215,6 +216,7 @@ export function AiPanelComposer({
legacyCopy,
onChange,
onSend,
onUnsupportedAiPaste,
}: AiPanelComposerProps) {
const composerDisabled = isActive || !agentReady
const canSend = !composerDisabled && input.trim().length > 0
@@ -240,6 +242,7 @@ export function AiPanelComposer({
value={input}
onChange={onChange}
onSend={onSend}
onUnsupportedPaste={onUnsupportedAiPaste}
disabled={composerDisabled}
placeholder={placeholder}
inputRef={inputRef}

View File

@@ -35,6 +35,7 @@ interface EditorProps {
activeTabPath: string | null
entries: VaultEntry[]
onNavigateWikilink: (target: string) => void
onUnsupportedAiPaste?: (message: string) => void
onLoadDiff?: (path: string) => Promise<string>
onLoadDiffAtCommit?: (path: string, commitHash: string) => Promise<string>
pendingCommitDiffRequest?: CommitDiffRequest | null
@@ -316,6 +317,7 @@ function EditorLayout({
onFileCreated,
onFileModified,
onVaultChanged,
onUnsupportedAiPaste,
}: {
tabs: Tab[]
activeTab: Tab | null
@@ -369,6 +371,7 @@ function EditorLayout({
onFileCreated?: (relativePath: string) => void
onFileModified?: (relativePath: string) => void
onVaultChanged?: () => void
onUnsupportedAiPaste?: (message: string) => void
}) {
return (
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
@@ -417,6 +420,7 @@ function EditorLayout({
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
entries={entries}
@@ -451,6 +455,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
getNoteStatus,
inspectorCollapsed, onToggleInspector, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onUnsupportedAiPaste,
onInspectorResize,
inspectorEntry, inspectorContent, gitHistory,
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
@@ -525,6 +530,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
inspectorWidth={inspectorWidth}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
onUnsupportedAiPaste={onUnsupportedAiPaste}
inspectorEntry={inspectorEntry}
inspectorContent={inspectorContent}
gitHistory={gitHistory}

View File

@@ -13,6 +13,7 @@ interface EditorRightPanelProps {
inspectorWidth: number
defaultAiAgent?: AiAgentId
defaultAiAgentReady?: boolean
onUnsupportedAiPaste?: (message: string) => void
inspectorEntry: VaultEntry | null
inspectorContent: string | null
entries: VaultEntry[]
@@ -40,6 +41,7 @@ interface EditorRightPanelProps {
export function EditorRightPanel({
showAIChat, inspectorCollapsed, inspectorWidth,
defaultAiAgent = DEFAULT_AI_AGENT, defaultAiAgentReady = true,
onUnsupportedAiPaste,
inspectorEntry, inspectorContent, entries, gitHistory, vaultPath,
noteList, noteListFilter,
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
@@ -81,6 +83,7 @@ export function EditorRightPanel({
controller={aiPanelController}
onClose={() => onToggleAIChat?.()}
onOpenNote={onOpenNote}
onUnsupportedAiPaste={onUnsupportedAiPaste}
defaultAiAgent={defaultAiAgent}
defaultAiAgentReady={defaultAiAgentReady}
activeEntry={inspectorEntry}

View File

@@ -37,6 +37,7 @@ interface InlineWikilinkInputProps {
value: string
onChange: (value: string) => void
onSubmit?: (text: string, references: NoteReference[]) => void
onUnsupportedPaste?: (message: string) => void
submitOnEmpty?: boolean
disabled?: boolean
placeholder?: string
@@ -56,6 +57,21 @@ function collapseSelectionRange(nextSelectionIndex: number) {
end: nextSelectionIndex,
}
}
export const UNSUPPORTED_INLINE_PASTE_MESSAGE = 'Only text paste is supported in the AI composer right now.'
function hasUnsupportedClipboardPayload(clipboardData: DataTransfer) {
if (clipboardData.files.length > 0) return true
return Array.from(clipboardData.items).some((item) =>
item.kind === 'file' || item.type.startsWith('image/'),
)
}
function containsUnsupportedInlineContent(editor: HTMLDivElement) {
return editor.querySelector('img, picture, video, audio, canvas, figure, iframe, object') !== null
}
function submitInlineValue({
onSubmit,
submitOnEmpty,
@@ -110,6 +126,7 @@ export function InlineWikilinkInput({
value,
onChange,
onSubmit,
onUnsupportedPaste,
submitOnEmpty = false,
disabled = false,
placeholder,
@@ -122,7 +139,7 @@ export function InlineWikilinkInput({
paletteEmptyState,
paletteFooter,
}: InlineWikilinkInputProps) {
const [, forceRender] = useState(0)
const [renderVersion, forceRender] = useState(0)
const segments = useMemo(
() => buildInlineWikilinkSegments(value, entries),
[entries, value],
@@ -171,15 +188,40 @@ export function InlineWikilinkInput({
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE)
const recoverUnsupportedMutation = () => {
pendingPasteRef.current = null
notifyUnsupportedPaste()
forceRender((current) => current + 1)
setSelectionRange({ ...selectionRange })
}
const deleteContent = (direction: 'backward' | 'forward') => {
const nextState = deleteInlineSelection(value, selectionRange, segments, direction)
if (!nextState) return
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handleBeforeInput = (event: React.FormEvent<HTMLDivElement>) => {
if (disabled) return
const nativeEvent = event.nativeEvent as InputEvent
if (!nativeEvent.inputType.startsWith('insert')) return
const dataTransfer = nativeEvent.dataTransfer
if (!dataTransfer || !hasUnsupportedClipboardPayload(dataTransfer)) return
event.preventDefault()
notifyUnsupportedPaste()
}
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
if (disabled) return
if (hasUnsupportedClipboardPayload(event.clipboardData)) {
event.preventDefault()
notifyUnsupportedPaste()
return
}
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
if (!pastedText) return
@@ -192,6 +234,11 @@ export function InlineWikilinkInput({
}
const handleInput = () => {
const editor = editorRef.current
if (editor && containsUnsupportedInlineContent(editor)) {
recoverUnsupportedMutation()
return
}
const pendingPaste = pendingPasteRef.current
if (editor && pendingPaste) {
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor))
@@ -223,12 +270,14 @@ export function InlineWikilinkInput({
})
const editor = (
<InlineWikilinkEditorField
key={renderVersion}
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onBeforeInput={handleBeforeInput}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}

View File

@@ -158,6 +158,7 @@ export function InlineWikilinkEditorField({
inputRef,
dataTestId,
editorClassName,
onBeforeInput,
onInput,
onKeyDown,
onPaste,
@@ -171,6 +172,7 @@ export function InlineWikilinkEditorField({
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
editorClassName?: string
onBeforeInput: (event: React.FormEvent<HTMLDivElement>) => void
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
@@ -204,6 +206,7 @@ export function InlineWikilinkEditorField({
disabled && 'cursor-not-allowed opacity-60',
editorClassName,
)}
onBeforeInput={onBeforeInput}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { WikilinkChatInput } from './WikilinkChatInput'
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
import type { VaultEntry } from '../types'
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
@@ -37,10 +38,12 @@ const entries: VaultEntry[] = [
function Controlled({
onSend,
onUnsupportedPaste,
disabled = false,
placeholder,
}: {
onSend?: (text: string, refs: Array<{ title: string; path: string; type: string | null }>) => void
onUnsupportedPaste?: (message: string) => void
disabled?: boolean
placeholder?: string
}) {
@@ -52,6 +55,7 @@ function Controlled({
value={value}
onChange={setValue}
onSend={onSend ?? vi.fn()}
onUnsupportedPaste={onUnsupportedPaste}
disabled={disabled}
placeholder={placeholder}
/>
@@ -181,4 +185,41 @@ describe('WikilinkChatInput', () => {
expect(editor).toHaveAttribute('contenteditable', 'false')
expect(editor).toHaveAttribute('aria-disabled', 'true')
})
it('rejects pasted images without freezing the editor', () => {
const onUnsupportedPaste = vi.fn()
render(<Controlled onUnsupportedPaste={onUnsupportedPaste} />)
const editor = screen.getByTestId('agent-input')
const clipboardData = {
getData: vi.fn(() => ''),
files: [new File(['image'], 'paste.png', { type: 'image/png' })],
items: [{ kind: 'file', type: 'image/png' }],
}
fireEvent.paste(editor, { clipboardData })
expect(onUnsupportedPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE)
updateEditorText('still works')
expect(editor.textContent).toContain('still works')
})
it('recovers if unsupported media lands in the editor DOM', async () => {
const onUnsupportedPaste = vi.fn()
render(<Controlled onUnsupportedPaste={onUnsupportedPaste} />)
const editor = screen.getByTestId('agent-input')
editor.innerHTML = '<img alt="paste" src="data:image/png;base64,abc" />'
fireEvent.input(editor)
expect(onUnsupportedPaste).toHaveBeenCalledWith(UNSUPPORTED_INLINE_PASTE_MESSAGE)
await waitFor(() => {
expect(screen.getByTestId('agent-input').querySelector('img')).toBeNull()
})
updateEditorText('still works')
expect(screen.getByTestId('agent-input').textContent).toContain('still works')
})
})

View File

@@ -7,6 +7,7 @@ interface WikilinkChatInputProps {
value: string
onChange: (value: string) => void
onSend: (text: string, references: NoteReference[]) => void
onUnsupportedPaste?: (message: string) => void
disabled?: boolean
placeholder?: string
inputRef?: React.RefObject<HTMLDivElement | null>
@@ -17,6 +18,7 @@ export function WikilinkChatInput({
value,
onChange,
onSend,
onUnsupportedPaste,
disabled,
placeholder,
inputRef,
@@ -27,6 +29,7 @@ export function WikilinkChatInput({
value={value}
onChange={onChange}
onSubmit={onSend}
onUnsupportedPaste={onUnsupportedPaste}
disabled={disabled}
placeholder={placeholder}
inputRef={inputRef}