Skip to content

Commit 33467eb

Browse files
committed
fix(chat): sync inline node deletion
1 parent 960f146 commit 33467eb

5 files changed

Lines changed: 149 additions & 135 deletions

File tree

src/renderer/src/components/chat/ChatInputBox.vue

Lines changed: 144 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -131,13 +131,17 @@ const files = useChatInputFiles(
131131
132132
// ── Inline Node action wiring ──────────────────────────────────
133133
let isSyncingNodes = false
134+
let isSubmittingCommandForm = false
134135
135136
const actions: InputNodeActions = {
137+
prepareCommandFormSubmit: () => {
138+
isSubmittingCommandForm = true
139+
},
136140
removeSkill: (skillName) => {
137141
void skillsData.deactivateSkill(skillName)
138142
},
139143
removeFile: (filePath) => {
140-
const idx = files.selectedFiles.value.findIndex((f) => f.path === filePath)
144+
const idx = files.selectedFiles.value.findIndex((f) => (f.path || f.name) === filePath)
141145
if (idx >= 0) {
142146
files.deleteFile(idx)
143147
}
@@ -186,121 +190,166 @@ const setCaretToEnd = (editor: Editor) => {
186190
editor.view.dispatch(editor.state.tr.setSelection(end))
187191
}
188192
189-
/** Insert a SkillChip node at the start of the first paragraph */
190-
function insertSkillChipNode(skillName: string) {
191-
const firstChild = editor.state.doc.firstChild
192-
if (!firstChild) return
193-
const insertPos = 1
194-
editor
195-
.chain()
196-
.focus()
197-
.insertContentAt(insertPos, { type: 'skillChip', attrs: { skillName } })
198-
.run()
199-
}
193+
type InlineNodeRange = { pos: number; size: number }
194+
const CHAT_INPUT_SYNC_META = 'chatInputSync'
200195
201-
/** Insert a FileAttachment node at the end of the first paragraph */
202-
function insertFileAttachmentNode(file: MessageFile) {
203-
const firstChild = editor.state.doc.firstChild
204-
if (!firstChild) return
205-
const insertPos = 1
206-
editor
207-
.chain()
208-
.focus()
209-
.insertContentAt(insertPos, {
210-
type: 'fileAttachment',
211-
attrs: {
212-
fileName: file.name || 'file',
213-
filePath: file.path || file.name,
214-
mimeType: file.mimeType || ''
215-
}
216-
})
217-
.run()
196+
function syncEditorContent(applyChange: () => void) {
197+
isSyncingNodes = true
198+
try {
199+
applyChange()
200+
} finally {
201+
isSyncingNodes = false
202+
}
218203
}
219204
220-
type InlineNodeRange = { pos: number; size: number }
221-
222205
function deleteInlineNodes(ranges: InlineNodeRange[]) {
223206
if (ranges.length === 0) return
224207
208+
let tr = editor.state.tr
225209
ranges
226210
.sort((a, b) => b.pos - a.pos)
227211
.forEach(({ pos, size }) => {
228-
editor
229-
.chain()
230-
.focus()
231-
.deleteRange({ from: pos, to: pos + size })
232-
.run()
212+
tr = tr.delete(pos, pos + size)
233213
})
234-
}
235214
236-
/** Ensure editor SkillChip nodes mirror skillsData.activeSkills */
237-
function syncSkillNodes() {
238-
if (isSyncingNodes) return
239-
const active = activeSkillNames.value
240-
const existing = new Map<string, InlineNodeRange>()
215+
editor.view.dispatch(tr.setMeta(CHAT_INPUT_SYNC_META, true).setMeta('addToHistory', false))
216+
}
241217
242-
editor.state.doc.descendants((node, pos) => {
218+
function getEditorSkillNames(): string[] {
219+
const names: string[] = []
220+
editor.state.doc.descendants((node) => {
243221
if (node.type.name === 'skillChip') {
244-
existing.set(node.attrs.skillName as string, { pos, size: node.nodeSize })
222+
names.push(node.attrs.skillName as string)
245223
}
246224
})
225+
return names
226+
}
247227
248-
deleteInlineNodes(
249-
Array.from(existing.entries())
250-
.filter(([name]) => !active.includes(name))
251-
.map(([, range]) => range)
252-
)
228+
function getEditorFilePaths(): string[] {
229+
const paths: string[] = []
230+
editor.state.doc.descendants((node) => {
231+
if (node.type.name === 'fileAttachment') {
232+
paths.push(node.attrs.filePath as string)
233+
}
234+
})
235+
return paths
236+
}
253237
254-
const newSkillNodes = active
255-
.filter((name) => !existing.has(name))
256-
.map((name) => ({
257-
type: 'skillChip',
258-
attrs: { skillName: name }
259-
}))
238+
function hasCommandFormNode(): boolean {
239+
let hasForm = false
240+
editor.state.doc.descendants((node) => {
241+
if (node.type.name === 'commandForm') {
242+
hasForm = true
243+
return false
244+
}
245+
return true
246+
})
247+
return hasForm
248+
}
249+
250+
function reconcileEditorNodes() {
251+
if (isSyncingNodes) return
260252
261-
if (newSkillNodes.length > 0) {
262-
editor.chain().focus().insertContentAt(editor.state.selection.from, newSkillNodes).run()
253+
const editorSkillNames = new Set(getEditorSkillNames())
254+
activeSkillNames.value
255+
.filter((name) => !editorSkillNames.has(name))
256+
.forEach((name) => {
257+
void skillsData.deactivateSkill(name)
258+
})
259+
260+
const editorFilePaths = new Set(getEditorFilePaths())
261+
for (let i = files.selectedFiles.value.length - 1; i >= 0; i -= 1) {
262+
const file = files.selectedFiles.value[i]
263+
if (!editorFilePaths.has(file.path || file.name)) {
264+
files.deleteFile(i)
265+
}
266+
}
267+
268+
if (!hasCommandFormNode() && !isSubmittingCommandForm) {
269+
mentions.closeDialog()
263270
}
264271
}
265272
266-
/** Ensure editor FileAttachment nodes mirror files.selectedFiles */
267-
function syncFileNodes() {
273+
/** Ensure editor SkillChip nodes mirror skillsData.activeSkills */
274+
function syncSkillNodes() {
268275
if (isSyncingNodes) return
269-
const currentFiles = files.selectedFiles.value
270-
const existing = new Map<string, InlineNodeRange>()
271276
272-
editor.state.doc.descendants((node, pos) => {
273-
if (node.type.name === 'fileAttachment') {
274-
const path = node.attrs.filePath as string
275-
existing.set(path, { pos, size: node.nodeSize })
277+
syncEditorContent(() => {
278+
const active = activeSkillNames.value
279+
const existing = new Map<string, InlineNodeRange>()
280+
281+
editor.state.doc.descendants((node, pos) => {
282+
if (node.type.name === 'skillChip') {
283+
existing.set(node.attrs.skillName as string, { pos, size: node.nodeSize })
284+
}
285+
})
286+
287+
deleteInlineNodes(
288+
Array.from(existing.entries())
289+
.filter(([name]) => !active.includes(name))
290+
.map(([, range]) => range)
291+
)
292+
293+
const newSkillNodes = active
294+
.filter((name) => !existing.has(name))
295+
.map((name) => ({
296+
type: 'skillChip',
297+
attrs: { skillName: name }
298+
}))
299+
300+
if (newSkillNodes.length > 0) {
301+
editor
302+
.chain()
303+
.insertContentAt(editor.state.selection.from, newSkillNodes, { updateSelection: false })
304+
.run()
276305
}
277306
})
307+
}
278308
279-
const currentPaths = new Set(currentFiles.map((f) => f.path || f.name))
309+
/** Ensure editor FileAttachment nodes mirror files.selectedFiles */
310+
function syncFileNodes() {
311+
if (isSyncingNodes) return
280312
281-
deleteInlineNodes(
282-
Array.from(existing.entries())
283-
.filter(([path]) => !currentPaths.has(path))
284-
.map(([, range]) => range)
285-
)
313+
syncEditorContent(() => {
314+
const currentFiles = files.selectedFiles.value
315+
const existing = new Map<string, InlineNodeRange>()
286316
287-
const newFileNodes = currentFiles
288-
.filter((file) => !existing.has(file.path || file.name))
289-
.map((file) => {
290-
const path = file.path || file.name
291-
return {
292-
type: 'fileAttachment',
293-
attrs: {
294-
fileName: file.name || 'file',
295-
filePath: path,
296-
mimeType: file.mimeType || ''
297-
}
317+
editor.state.doc.descendants((node, pos) => {
318+
if (node.type.name === 'fileAttachment') {
319+
const path = node.attrs.filePath as string
320+
existing.set(path, { pos, size: node.nodeSize })
298321
}
299322
})
300323
301-
if (newFileNodes.length > 0) {
302-
editor.chain().focus().insertContentAt(findFileInsertPos(), newFileNodes).run()
303-
}
324+
const currentPaths = new Set(currentFiles.map((f) => f.path || f.name))
325+
326+
deleteInlineNodes(
327+
Array.from(existing.entries())
328+
.filter(([path]) => !currentPaths.has(path))
329+
.map(([, range]) => range)
330+
)
331+
332+
const newFileNodes = currentFiles
333+
.filter((file) => !existing.has(file.path || file.name))
334+
.map((file) => {
335+
const path = file.path || file.name
336+
return {
337+
type: 'fileAttachment',
338+
attrs: {
339+
fileName: file.name || 'file',
340+
filePath: path,
341+
mimeType: file.mimeType || ''
342+
}
343+
}
344+
})
345+
346+
if (newFileNodes.length > 0) {
347+
editor
348+
.chain()
349+
.insertContentAt(findFileInsertPos(), newFileNodes, { updateSelection: false })
350+
.run()
351+
}
352+
})
304353
}
305354
306355
function findFileInsertPos(): number {
@@ -350,7 +399,12 @@ const editor = new VueEditor({
350399
})
351400
],
352401
content: toEditorDoc(props.modelValue || ''),
353-
onUpdate: ({ editor }) => {
402+
onUpdate: ({ editor, transaction }) => {
403+
if (!transaction.getMeta(CHAT_INPUT_SYNC_META)) {
404+
reconcileEditorNodes()
405+
}
406+
isSubmittingCommandForm = false
407+
354408
const text = getEditorText(editor)
355409
if (text !== (props.modelValue || '')) {
356410
emit('update:modelValue', text)
@@ -369,10 +423,10 @@ watch(
369423
const current = getEditorText(editor)
370424
if (next === current) return
371425
372-
isSyncingNodes = true
373-
editor.commands.setContent(toEditorDoc(next), false)
374-
setCaretToEnd(editor)
375-
isSyncingNodes = false
426+
syncEditorContent(() => {
427+
editor.commands.setContent(toEditorDoc(next), false)
428+
setCaretToEnd(editor)
429+
})
376430
377431
// Re-sync chips after content replacement
378432
void nextTick(() => {
@@ -559,32 +613,6 @@ function insertRecognizedText(text: string) {
559613
editor.chain().focus().insertContent(normalizedText).run()
560614
}
561615
562-
// ── Public helpers to extract skills/files from editor ─────────
563-
564-
function getEditorSkillNames(): string[] {
565-
const names: string[] = []
566-
editor.state.doc.descendants((node) => {
567-
if (node.type.name === 'skillChip') {
568-
names.push(node.attrs.skillName as string)
569-
}
570-
})
571-
return names
572-
}
573-
574-
function getEditorFileAttachments(): { name: string; path: string; mimeType: string }[] {
575-
const result: { name: string; path: string; mimeType: string }[] = []
576-
editor.state.doc.descendants((node) => {
577-
if (node.type.name === 'fileAttachment') {
578-
result.push({
579-
name: node.attrs.fileName as string,
580-
path: node.attrs.filePath as string,
581-
mimeType: node.attrs.mimeType as string
582-
})
583-
}
584-
})
585-
return result
586-
}
587-
588616
function getPendingSkillsSnapshot(): string[] {
589617
return Array.from(new Set(skillsData.pendingSkills.value))
590618
}
@@ -606,8 +634,6 @@ defineExpose({
606634
triggerAttach,
607635
insertRecognizedText,
608636
insertWorkspaceReference,
609-
getEditorSkillNames,
610-
getEditorFileAttachments,
611637
getPendingSkillsSnapshot,
612638
consumePendingSkills,
613639
clearPendingSkills,

src/renderer/src/components/chat/composables/useChatInputMentions.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,7 @@ export function useChatInputMentions(options: UseChatInputMentionsOptions) {
340340
}
341341

342342
if (action.kind === 'activate-skill') {
343-
// Insert chip at the trigger position, then activate
344-
editor
345-
.chain()
346-
.focus()
347-
.insertContentAt(range, '')
348-
.insertContentAt(range.from, {
349-
type: 'skillChip',
350-
attrs: { skillName: action.skillName }
351-
})
352-
.run()
343+
editor.chain().focus().insertContentAt(range, '').run()
353344

354345
if (options.onActivateSkill) {
355346
await options.onActivateSkill(action.skillName)

src/renderer/src/components/chat/nodes/CommandFormView.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ function handleFieldEnter(index: number, event: KeyboardEvent) {
113113
114114
function handleSubmit() {
115115
if (!canSubmit.value) return
116+
actions?.prepareCommandFormSubmit()
116117
props.deleteNode()
117118
if (actions?.submitCommandForm) {
118119
actions.submitCommandForm({ ...formValues })

src/renderer/src/components/chat/nodes/symbols.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { InjectionKey } from 'vue'
22

33
export interface InputNodeActions {
4+
prepareCommandFormSubmit: () => void
45
removeSkill: (skillName: string) => void
56
removeFile: (filePath: string) => void
67
submitCommandForm: (values: Record<string, string>) => void

test/renderer/components/ChatInputBox.test.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -378,20 +378,15 @@ describe('ChatInputBox attachments', () => {
378378
expect(handleDropMock).not.toHaveBeenCalled()
379379
})
380380

381-
it('handles remove attached file via exposed helpers', async () => {
382-
const wrapper = await mountComponent({
381+
it('tracks attached file state via the files composable', async () => {
382+
await mountComponent({
383383
files: [{ name: 'a.txt', path: '/tmp/a.txt' }]
384384
})
385385
selectedFilesRef.value = [{ name: 'a.txt', path: '/tmp/a.txt' }]
386386
await nextTick()
387387

388-
// Verify files are tracked in the editor node model
389-
const attachments = (wrapper.vm as any).getEditorFileAttachments?.() ?? []
390-
// The test mock doesn't render real nodes so no actual nodes exist,
391-
// but the composable should still track the file
392388
expect(selectedFilesRef.value.length).toBe(1)
393389

394-
// Trigger delete through the composable
395390
deleteFileMock(0)
396391
expect(deleteFileMock).toHaveBeenCalledWith(0)
397392
})

0 commit comments

Comments
 (0)