11import { setIcon } from 'obsidian' ;
22
33import type { ChatTurnRequest } from '../../../core/runtime/types' ;
4+ import { t } from '../../../i18n/i18n' ;
45import { appendMarkdownSnippet } from '../../../shared/markdown/markdown' ;
56import type { ChatState } from '../state/chat-state' ;
67import type { QueuedMessage } from '../state/types' ;
78import type { ImageContextManager } from '../ui/image-context' ;
89import {
910 cloneChatTurnRequest ,
10- mergeQueuedChatTurns ,
1111 type QueuedChatTurn ,
1212} from './queued-turn' ;
1313
@@ -19,83 +19,134 @@ export interface QueuedMessageControllerDeps {
1919 sendQueuedTurn : ( message : QueuedChatTurn ) => void ;
2020}
2121
22- /** Owns the single queued turn, its composer projection, and indicator UI. */
22+ let nextQueuedMessageId = 1 ;
23+
24+ /** Owns the FIFO queue of pending turns, its composer projection, and list UI. */
2325export class QueuedMessageController {
26+ private collapsed = false ;
27+ private draggingId : string | null = null ;
28+
2429 constructor ( private readonly deps : QueuedMessageControllerDeps ) { }
2530
2631 enqueue ( displayContent : string , turnRequest : ChatTurnRequest ) : void {
2732 const incoming = this . createQueuedMessage ( displayContent , turnRequest ) ;
28- this . deps . state . queuedMessage = this . mergeQueuedMessages (
29- this . deps . state . queuedMessage ,
30- incoming ,
31- ) ;
33+ this . deps . state . queuedMessages = [ ...this . deps . state . queuedMessages , incoming ] ;
3234 this . updateIndicator ( ) ;
3335 }
3436
3537 updateIndicator ( ) : void {
3638 const { state } = this . deps ;
37- const indicatorEl = state . queueIndicatorEl ;
38- if ( ! indicatorEl ) return ;
39- indicatorEl . empty ( ) ;
40-
41- const message = state . queuedMessage ;
42- if ( ! message ) {
43- indicatorEl . removeClass ( 'qoderian-visible-flex' ) ;
44- indicatorEl . addClass ( 'qoderian-hidden' ) ;
39+ const containerEl = state . queueIndicatorEl ;
40+ if ( ! containerEl ) return ;
41+ containerEl . empty ( ) ;
42+
43+ const messages = state . queuedMessages ;
44+ if ( messages . length === 0 ) {
45+ containerEl . removeClass ( 'qoderian-visible-flex' ) ;
46+ containerEl . addClass ( 'qoderian-hidden' ) ;
4547 return ;
4648 }
4749
48- indicatorEl . createSpan ( {
49- cls : 'qoderian-queue-indicator-text' ,
50- text : `⌙ Queued: ${ this . getQueuedMessageDisplay ( message ) } ` ,
50+ const headerEl = containerEl . createDiv ( { cls : 'qoderian-queue-header' } ) ;
51+ const toggleEl = headerEl . createEl ( 'button' , {
52+ cls : 'qoderian-queue-header-toggle' ,
53+ attr : {
54+ 'aria-label' : this . collapsed ? t ( 'chat.queue.expand' ) : t ( 'chat.queue.collapse' ) ,
55+ title : this . collapsed ? t ( 'chat.queue.expand' ) : t ( 'chat.queue.collapse' ) ,
56+ type : 'button' ,
57+ } ,
5158 } ) ;
52- const actionsEl = indicatorEl . createDiv ( { cls : 'qoderian-queue-indicator-actions' } ) ;
53- const editButton = this . createIconButton ( actionsEl , 'pencil' , 'Edit queued message' ) ;
54- editButton . addEventListener ( 'click' , ( event ) => {
59+ setIcon ( toggleEl , this . collapsed ? 'chevron-right' : 'chevron-down' ) ;
60+ toggleEl . addEventListener ( 'click' , ( event ) => {
5561 event . stopPropagation ( ) ;
56- this . withdrawToComposer ( ) ;
62+ this . collapsed = ! this . collapsed ;
63+ this . updateIndicator ( ) ;
5764 } ) ;
58- const discardButton = this . createIconButton ( actionsEl , 'trash-2' , 'Discard queued message' ) ;
59- discardButton . addEventListener ( 'click' , ( event ) => {
60- event . stopPropagation ( ) ;
61- this . clear ( ) ;
65+ headerEl . createSpan ( {
66+ cls : 'qoderian-queue-header-title' ,
67+ text : t ( 'chat.queue.title' , { count : messages . length } ) ,
6268 } ) ;
63- indicatorEl . addClass ( 'qoderian-visible-flex' ) ;
64- indicatorEl . removeClass ( 'qoderian-hidden' ) ;
69+
70+ if ( ! this . collapsed ) {
71+ const listEl = containerEl . createDiv ( { cls : 'qoderian-queue-list' } ) ;
72+ for ( const message of messages ) {
73+ this . renderRow ( listEl , message ) ;
74+ }
75+ }
76+
77+ containerEl . addClass ( 'qoderian-visible-flex' ) ;
78+ containerEl . removeClass ( 'qoderian-hidden' ) ;
6579 }
6680
6781 clear ( ) : void {
68- this . deps . state . queuedMessage = null ;
82+ this . deps . state . queuedMessages = [ ] ;
6983 this . updateIndicator ( ) ;
7084 }
7185
72- withdrawToComposer ( ) : void {
73- const { state } = this . deps ;
74- if ( ! state . queuedMessage ) return ;
75- const queuedMessage = this . cloneQueuedMessage ( state . queuedMessage ) ;
76- state . queuedMessage = null ;
77- this . restoreMessageToInput ( queuedMessage , true ) ;
86+ /** Remove one item by id. */
87+ discard ( id : string ) : void {
88+ const messages = this . deps . state . queuedMessages ;
89+ if ( ! messages . some ( message => message . id === id ) ) return ;
90+ this . deps . state . queuedMessages = messages . filter ( message => message . id !== id ) ;
7891 this . updateIndicator ( ) ;
7992 }
8093
81- restorePendingToComposer ( ) : void {
94+ /** Withdraw one item back into the composer. */
95+ withdrawToComposer ( id : string ) : void {
8296 const { state } = this . deps ;
83- this . restoreMessageToInput ( state . queuedMessage , true ) ;
84- state . queuedMessage = null ;
97+ const target = state . queuedMessages . find ( message => message . id === id ) ;
98+ if ( ! target ) return ;
99+ state . queuedMessages = state . queuedMessages . filter ( message => message . id !== id ) ;
100+ this . restoreMessageToInput ( target , true ) ;
85101 this . updateIndicator ( ) ;
86102 }
87103
104+ /** Drain the head of the queue at turn end. */
88105 process ( ) : void {
89106 const { state } = this . deps ;
90- if ( ! state . queuedMessage ) return ;
91- const queuedMessage = this . cloneQueuedMessage ( state . queuedMessage ) ;
92- state . queuedMessage = null ;
107+ const next = state . queuedMessages [ 0 ] ;
108+ if ( ! next ) return ;
109+ state . queuedMessages = state . queuedMessages . slice ( 1 ) ;
93110 this . updateIndicator ( ) ;
94- window . setTimeout ( ( ) => this . deps . sendQueuedTurn ( this . toQueuedChatTurn ( queuedMessage ) ) , 0 ) ;
111+ window . setTimeout ( ( ) => this . deps . sendQueuedTurn ( this . toQueuedChatTurn ( next ) ) , 0 ) ;
95112 }
96113
97- private restoreMessageToInput ( message : QueuedMessage | null , mergeWithComposer : boolean ) : void {
98- if ( ! message ) return ;
114+ private renderRow ( listEl : HTMLElement , message : QueuedMessage ) : void {
115+ const rowEl = listEl . createDiv ( { cls : 'qoderian-queue-row' } ) ;
116+ rowEl . dataset . queueId = message . id ;
117+
118+ const handleEl = rowEl . createSpan ( {
119+ cls : 'qoderian-queue-row-handle' ,
120+ attr : {
121+ 'aria-label' : t ( 'chat.queue.drag' ) ,
122+ title : t ( 'chat.queue.dragTooltip' ) ,
123+ draggable : 'true' ,
124+ } ,
125+ } ) ;
126+ setIcon ( handleEl , 'grip-vertical' ) ;
127+ this . attachDragHandlers ( handleEl , rowEl ) ;
128+
129+ rowEl . createSpan ( {
130+ cls : 'qoderian-queue-row-text' ,
131+ text : this . getQueuedMessageDisplay ( message ) ,
132+ attr : { title : message . content . trim ( ) } ,
133+ } ) ;
134+
135+ const actionsEl = rowEl . createDiv ( { cls : 'qoderian-queue-row-actions' } ) ;
136+ const editEl = this . createIconButton ( actionsEl , 'pencil' , t ( 'chat.queue.edit' ) ) ;
137+ editEl . addEventListener ( 'click' , ( event ) => {
138+ event . stopPropagation ( ) ;
139+ this . withdrawToComposer ( message . id ) ;
140+ } ) ;
141+
142+ const deleteEl = this . createIconButton ( actionsEl , 'trash-2' , t ( 'chat.queue.delete' ) ) ;
143+ deleteEl . addEventListener ( 'click' , ( event ) => {
144+ event . stopPropagation ( ) ;
145+ this . discard ( message . id ) ;
146+ } ) ;
147+ }
148+
149+ private restoreMessageToInput ( message : QueuedMessage , mergeWithComposer : boolean ) : void {
99150 const inputEl = this . deps . getInputEl ( ) ;
100151 const currentContent = mergeWithComposer ? inputEl . value . trim ( ) : '' ;
101152 inputEl . value = currentContent
@@ -119,6 +170,49 @@ export class QueuedMessageController {
119170 return preview ;
120171 }
121172
173+ private attachDragHandlers ( handleEl : HTMLElement , rowEl : HTMLElement ) : void {
174+ handleEl . addEventListener ( 'dragstart' , ( event ) => {
175+ this . draggingId = rowEl . dataset . queueId ?? null ;
176+ rowEl . addClass ( 'qoderian-queue-row-dragging' ) ;
177+ if ( event . dataTransfer ) {
178+ event . dataTransfer . effectAllowed = 'move' ;
179+ event . dataTransfer . setData ( 'text/plain' , this . draggingId ?? '' ) ;
180+ }
181+ } ) ;
182+ handleEl . addEventListener ( 'dragend' , ( ) => {
183+ rowEl . removeClass ( 'qoderian-queue-row-dragging' ) ;
184+ this . draggingId = null ;
185+ this . commitDomOrder ( ) ;
186+ } ) ;
187+ rowEl . addEventListener ( 'dragover' , ( event ) => {
188+ if ( ! this . draggingId || rowEl . dataset . queueId === this . draggingId ) return ;
189+ event . preventDefault ( ) ;
190+ const listEl = rowEl . parentElement ;
191+ const draggingEl = listEl ?. querySelector ( `[data-queue-id="${ this . draggingId } "]` ) ;
192+ if ( ! listEl || ! draggingEl ) return ;
193+ const rect = rowEl . getBoundingClientRect ( ) ;
194+ const before = event . clientY < rect . top + rect . height / 2 ;
195+ listEl . insertBefore ( draggingEl , before ? rowEl : rowEl . nextSibling ) ;
196+ } ) ;
197+ }
198+
199+ /** Persist the DOM order after a drag ends. */
200+ private commitDomOrder ( ) : void {
201+ const { state } = this . deps ;
202+ const containerEl = state . queueIndicatorEl ;
203+ if ( ! containerEl ) return ;
204+ const ids = [ ...containerEl . querySelectorAll < HTMLElement > ( '[data-queue-id]' ) ]
205+ . map ( el => el . dataset . queueId ?? '' ) ;
206+ const byId = new Map ( state . queuedMessages . map ( message => [ message . id , message ] ) ) ;
207+ const reordered = ids
208+ . map ( id => byId . get ( id ) )
209+ . filter ( ( message ) : message is QueuedMessage => Boolean ( message ) ) ;
210+ for ( const message of state . queuedMessages ) {
211+ if ( ! ids . includes ( message . id ) ) reordered . push ( message ) ;
212+ }
213+ state . queuedMessages = reordered ;
214+ }
215+
122216 private createIconButton ( parentEl : HTMLElement , icon : string , label : string ) : HTMLElement {
123217 const button = parentEl . createEl ( 'button' , {
124218 cls : 'qoderian-queue-indicator-icon-action' ,
@@ -128,17 +222,10 @@ export class QueuedMessageController {
128222 return button ;
129223 }
130224
131- private cloneQueuedMessage ( message : QueuedMessage ) : QueuedMessage {
132- return {
133- ...message ,
134- images : message . images ? [ ...message . images ] : undefined ,
135- turnRequest : message . turnRequest ? cloneChatTurnRequest ( message . turnRequest ) : undefined ,
136- } ;
137- }
138-
139225 private createQueuedMessage ( displayContent : string , turnRequest : ChatTurnRequest ) : QueuedMessage {
140226 const request = cloneChatTurnRequest ( turnRequest ) ;
141227 return {
228+ id : `queued-${ nextQueuedMessageId ++ } ` ,
142229 content : displayContent ,
143230 images : request . images ,
144231 editorContext : request . editorSelection ?? null ,
@@ -166,13 +253,4 @@ export class QueuedMessageController {
166253 } ,
167254 } ;
168255 }
169-
170- private mergeQueuedMessages ( existing : QueuedMessage | null , incoming : QueuedMessage ) : QueuedMessage {
171- if ( ! existing ) return this . cloneQueuedMessage ( incoming ) ;
172- const mergedTurn = mergeQueuedChatTurns (
173- this . toQueuedChatTurn ( existing ) ,
174- this . toQueuedChatTurn ( incoming ) ,
175- ) ;
176- return this . createQueuedMessage ( mergedTurn . displayContent , mergedTurn . request ) ;
177- }
178256}
0 commit comments