fix: keep ai composer responsive after image paste
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user