@@ -131,13 +131,17 @@ const files = useChatInputFiles(
131131
132132// ── Inline Node action wiring ──────────────────────────────────
133133let isSyncingNodes = false
134+ let isSubmittingCommandForm = false
134135
135136const 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-
222205function 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
306355function 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-
588616function 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 ,
0 commit comments