Skip to content

Commit b3baa85

Browse files
committed
Message queue
1 parent abb2af7 commit b3baa85

File tree

6 files changed

+232
-4
lines changed

6 files changed

+232
-4
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './copilot-message/copilot-message'
22
export * from './plan-mode-section/plan-mode-section'
3+
export * from './queued-messages/queued-messages'
34
export * from './todo-list/todo-list'
45
export * from './tool-call/tool-call'
56
export * from './user-input/user-input'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use client'
2+
3+
import { useCallback, useState } from 'react'
4+
import { ArrowUp, ChevronDown, ChevronRight, MoreHorizontal, Trash2 } from 'lucide-react'
5+
import { useCopilotStore } from '@/stores/panel/copilot/store'
6+
7+
/**
8+
* Displays queued messages in a Cursor-style collapsible panel above the input box.
9+
*/
10+
export function QueuedMessages() {
11+
const messageQueue = useCopilotStore((s) => s.messageQueue)
12+
const removeFromQueue = useCopilotStore((s) => s.removeFromQueue)
13+
const sendNow = useCopilotStore((s) => s.sendNow)
14+
15+
const [isExpanded, setIsExpanded] = useState(true)
16+
17+
const handleRemove = useCallback(
18+
(id: string) => {
19+
removeFromQueue(id)
20+
},
21+
[removeFromQueue]
22+
)
23+
24+
const handleSendNow = useCallback(
25+
async (id: string) => {
26+
await sendNow(id)
27+
},
28+
[sendNow]
29+
)
30+
31+
if (messageQueue.length === 0) return null
32+
33+
return (
34+
<div className='mx-2 overflow-hidden rounded-t-lg border border-b-0 border-black/[0.08] bg-[var(--bg-secondary)] dark:border-white/[0.08]'>
35+
{/* Header */}
36+
<button
37+
type='button'
38+
onClick={() => setIsExpanded(!isExpanded)}
39+
className='flex w-full items-center justify-between px-2.5 py-1.5 transition-colors hover:bg-[var(--bg-tertiary)]'
40+
>
41+
<div className='flex items-center gap-1.5'>
42+
{isExpanded ? (
43+
<ChevronDown className='h-3 w-3 text-[var(--text-tertiary)]' />
44+
) : (
45+
<ChevronRight className='h-3 w-3 text-[var(--text-tertiary)]' />
46+
)}
47+
<span className='text-xs font-medium text-[var(--text-secondary)]'>
48+
{messageQueue.length} Queued
49+
</span>
50+
</div>
51+
<MoreHorizontal className='h-3 w-3 text-[var(--text-tertiary)]' />
52+
</button>
53+
54+
{/* Message list */}
55+
{isExpanded && (
56+
<div>
57+
{messageQueue.map((msg, index) => (
58+
<div
59+
key={msg.id}
60+
className='group flex items-center gap-2 border-t border-black/[0.04] px-2.5 py-1.5 hover:bg-[var(--bg-tertiary)] dark:border-white/[0.04]'
61+
>
62+
{/* Radio indicator */}
63+
<div className='flex h-3 w-3 shrink-0 items-center justify-center'>
64+
<div className='h-2.5 w-2.5 rounded-full border border-[var(--text-tertiary)]/50' />
65+
</div>
66+
67+
{/* Message content */}
68+
<div className='min-w-0 flex-1'>
69+
<p className='truncate text-xs text-[var(--text-primary)]'>
70+
{msg.content}
71+
</p>
72+
</div>
73+
74+
{/* Actions */}
75+
<div className='flex shrink-0 items-center gap-1'>
76+
{/* Send immediately button */}
77+
<button
78+
type='button'
79+
onClick={(e) => {
80+
e.stopPropagation()
81+
handleSendNow(msg.id)
82+
}}
83+
className='rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
84+
title='Send immediately (stops current)'
85+
>
86+
<ArrowUp className='h-3.5 w-3.5' />
87+
</button>
88+
{/* Delete button */}
89+
<button
90+
type='button'
91+
onClick={(e) => {
92+
e.stopPropagation()
93+
handleRemove(msg.id)
94+
}}
95+
className='rounded p-1 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-tertiary)] hover:text-red-400'
96+
title='Remove from queue'
97+
>
98+
<Trash2 className='h-3.5 w-3.5' />
99+
</button>
100+
</div>
101+
</div>
102+
))}
103+
</div>
104+
)}
105+
</div>
106+
)
107+
}
108+

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,8 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
300300
async (overrideMessage?: string, options: { preserveInput?: boolean } = {}) => {
301301
const targetMessage = overrideMessage ?? message
302302
const trimmedMessage = targetMessage.trim()
303-
if (!trimmedMessage || disabled || isLoading) return
303+
// Allow submission even when isLoading - store will queue the message
304+
if (!trimmedMessage || disabled) return
304305

305306
const failedUploads = fileAttachments.attachedFiles.filter((f) => !f.uploading && !f.key)
306307
if (failedUploads.length > 0) {

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/copilot.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Trash } from '@/components/emcn/icons/trash'
2525
import {
2626
CopilotMessage,
2727
PlanModeSection,
28+
QueuedMessages,
2829
TodoList,
2930
UserInput,
3031
Welcome,
@@ -298,7 +299,8 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
298299
*/
299300
const handleSubmit = useCallback(
300301
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
301-
if (!query || isSendingMessage || !activeWorkflowId) return
302+
// Allow submission even when isSendingMessage - store will queue the message
303+
if (!query || !activeWorkflowId) return
302304

303305
if (showPlanTodos) {
304306
const store = useCopilotStore.getState()
@@ -316,7 +318,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
316318
logger.error('Failed to send message:', error)
317319
}
318320
},
319-
[isSendingMessage, activeWorkflowId, sendMessage, showPlanTodos]
321+
[activeWorkflowId, sendMessage, showPlanTodos]
320322
)
321323

322324
/**
@@ -588,6 +590,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
588590
)}
589591
</div>
590592

593+
{/* Queued messages (shown when messages are waiting) */}
594+
<QueuedMessages />
595+
591596
{/* Input area with integrated mode selector */}
592597
<div className='flex-shrink-0 px-[8px] pb-[8px]'>
593598
<UserInput

apps/sim/stores/panel/copilot/store.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1944,6 +1944,7 @@ const initialState = {
19441944
suppressAutoSelect: false,
19451945
contextUsage: null,
19461946
autoAllowedTools: [] as string[],
1947+
messageQueue: [] as import('./types').QueuedMessage[],
19471948
}
19481949

19491950
export const useCopilotStore = create<CopilotStore>()(
@@ -2301,7 +2302,7 @@ export const useCopilotStore = create<CopilotStore>()(
23012302

23022303
// Send a message (streaming only)
23032304
sendMessage: async (message: string, options = {}) => {
2304-
const { workflowId, currentChat, mode, revertState } = get()
2305+
const { workflowId, currentChat, mode, revertState, isSendingMessage } = get()
23052306
const {
23062307
stream = true,
23072308
fileAttachments,
@@ -2316,6 +2317,13 @@ export const useCopilotStore = create<CopilotStore>()(
23162317

23172318
if (!workflowId) return
23182319

2320+
// If already sending a message, queue this one instead
2321+
if (isSendingMessage) {
2322+
get().addToQueue(message, { fileAttachments, contexts })
2323+
logger.info('[Copilot] Message queued (already sending)', { queueLength: get().messageQueue.length + 1 })
2324+
return
2325+
}
2326+
23192327
const abortController = new AbortController()
23202328
set({ isSendingMessage: true, error: null, abortController })
23212329

@@ -3044,6 +3052,23 @@ export const useCopilotStore = create<CopilotStore>()(
30443052
await get().handleNewChatCreation(context.newChatId)
30453053
}
30463054

3055+
// Process next message in queue if any
3056+
const nextInQueue = get().messageQueue[0]
3057+
if (nextInQueue) {
3058+
logger.info('[Queue] Processing next queued message', { id: nextInQueue.id, queueLength: get().messageQueue.length })
3059+
// Remove from queue and send
3060+
get().removeFromQueue(nextInQueue.id)
3061+
// Use setTimeout to avoid blocking the current execution
3062+
setTimeout(() => {
3063+
get().sendMessage(nextInQueue.content, {
3064+
stream: true,
3065+
fileAttachments: nextInQueue.fileAttachments,
3066+
contexts: nextInQueue.contexts,
3067+
messageId: nextInQueue.id,
3068+
})
3069+
}, 100)
3070+
}
3071+
30473072
// Persist full message state (including contentBlocks), plan artifact, and config to database
30483073
const { currentChat, streamingPlanContent, mode, selectedModel } = get()
30493074
if (currentChat) {
@@ -3526,6 +3551,66 @@ export const useCopilotStore = create<CopilotStore>()(
35263551
const { autoAllowedTools } = get()
35273552
return autoAllowedTools.includes(toolId)
35283553
},
3554+
3555+
// Message queue actions
3556+
addToQueue: (message, options) => {
3557+
const queuedMessage: import('./types').QueuedMessage = {
3558+
id: crypto.randomUUID(),
3559+
content: message,
3560+
fileAttachments: options?.fileAttachments,
3561+
contexts: options?.contexts,
3562+
queuedAt: Date.now(),
3563+
}
3564+
set({ messageQueue: [...get().messageQueue, queuedMessage] })
3565+
logger.info('[Queue] Message added to queue', { id: queuedMessage.id, queueLength: get().messageQueue.length })
3566+
},
3567+
3568+
removeFromQueue: (id) => {
3569+
set({ messageQueue: get().messageQueue.filter((m) => m.id !== id) })
3570+
logger.info('[Queue] Message removed from queue', { id, queueLength: get().messageQueue.length })
3571+
},
3572+
3573+
moveUpInQueue: (id) => {
3574+
const queue = [...get().messageQueue]
3575+
const index = queue.findIndex((m) => m.id === id)
3576+
if (index > 0) {
3577+
const item = queue[index]
3578+
queue.splice(index, 1)
3579+
queue.splice(index - 1, 0, item)
3580+
set({ messageQueue: queue })
3581+
logger.info('[Queue] Message moved up in queue', { id, newIndex: index - 1 })
3582+
}
3583+
},
3584+
3585+
sendNow: async (id) => {
3586+
const queue = get().messageQueue
3587+
const message = queue.find((m) => m.id === id)
3588+
if (!message) return
3589+
3590+
// Remove from queue first
3591+
get().removeFromQueue(id)
3592+
3593+
// If currently sending, abort and send this one
3594+
const { isSendingMessage } = get()
3595+
if (isSendingMessage) {
3596+
get().abortMessage()
3597+
// Wait a tick for abort to complete
3598+
await new Promise((resolve) => setTimeout(resolve, 50))
3599+
}
3600+
3601+
// Send the message
3602+
await get().sendMessage(message.content, {
3603+
stream: true,
3604+
fileAttachments: message.fileAttachments,
3605+
contexts: message.contexts,
3606+
messageId: message.id,
3607+
})
3608+
},
3609+
3610+
clearQueue: () => {
3611+
set({ messageQueue: [] })
3612+
logger.info('[Queue] Queue cleared')
3613+
},
35293614
}))
35303615
)
35313616

apps/sim/stores/panel/copilot/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ export interface CopilotMessage {
6060
errorType?: 'usage_limit' | 'unauthorized' | 'forbidden' | 'rate_limit' | 'upgrade_required'
6161
}
6262

63+
/**
64+
* A message queued for sending while another message is in progress.
65+
* Like Cursor's queued message feature.
66+
*/
67+
export interface QueuedMessage {
68+
id: string
69+
content: string
70+
fileAttachments?: MessageFileAttachment[]
71+
contexts?: ChatContext[]
72+
queuedAt: number
73+
}
74+
6375
// Contexts attached to a user message
6476
export type ChatContext =
6577
| { kind: 'past_chat'; chatId: string; label: string }
@@ -161,6 +173,9 @@ export interface CopilotState {
161173

162174
// Auto-allowed integration tools (tools that can run without confirmation)
163175
autoAllowedTools: string[]
176+
177+
// Message queue for messages sent while another is in progress
178+
messageQueue: QueuedMessage[]
164179
}
165180

166181
export interface CopilotActions {
@@ -238,6 +253,19 @@ export interface CopilotActions {
238253
addAutoAllowedTool: (toolId: string) => Promise<void>
239254
removeAutoAllowedTool: (toolId: string) => Promise<void>
240255
isToolAutoAllowed: (toolId: string) => boolean
256+
257+
// Message queue actions
258+
addToQueue: (
259+
message: string,
260+
options?: {
261+
fileAttachments?: MessageFileAttachment[]
262+
contexts?: ChatContext[]
263+
}
264+
) => void
265+
removeFromQueue: (id: string) => void
266+
moveUpInQueue: (id: string) => void
267+
sendNow: (id: string) => Promise<void>
268+
clearQueue: () => void
241269
}
242270

243271
export type CopilotStore = CopilotState & CopilotActions

0 commit comments

Comments
 (0)