Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions src/renderer/features/agents/components/edit-queued-message-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
"use client"

import { AnimatePresence, motion } from "motion/react"
import { useEffect, useState, useRef, useCallback } from "react"
import { createPortal } from "react-dom"
import { Button } from "../../../components/ui/button"
import { Textarea } from "../../../components/ui/textarea"
import type { AgentQueueItem } from "../lib/queue-utils"
import { useMessageQueueStore } from "../stores/message-queue-store"

interface EditQueuedMessageDialogProps {
isOpen: boolean
onClose: () => void
item: AgentQueueItem | null
subChatId: string
isFirstInQueue?: boolean
}

const EASING_CURVE = [0.55, 0.055, 0.675, 0.19] as const
const INTERACTION_DELAY_MS = 250

export function EditQueuedMessageDialog({
isOpen,
onClose,
item,
subChatId,
isFirstInQueue = false,
}: EditQueuedMessageDialogProps) {
const [mounted, setMounted] = useState(false)
const [message, setMessage] = useState("")
const openAtRef = useRef<number>(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)

const updateQueueItem = useMessageQueueStore((s) => s.updateQueueItem)
const setEditingItem = useMessageQueueStore((s) => s.setEditingItem)

useEffect(() => {
setMounted(true)
}, [])

useEffect(() => {
if (isOpen && item) {
openAtRef.current = performance.now()
setMessage(item.message)
// If editing the first item in queue, pause processing
if (isFirstInQueue) {
setEditingItem(item.id, true)
}
}
}, [isOpen, item, isFirstInQueue, setEditingItem])

const handleAnimationComplete = () => {
if (isOpen) {
textareaRef.current?.focus()
// Select all text
textareaRef.current?.select()
}
}

const handleClose = useCallback(() => {
const canInteract = performance.now() - openAtRef.current > INTERACTION_DELAY_MS
if (!canInteract) return
// Clear editing state when closing
if (item && isFirstInQueue) {
setEditingItem(item.id, false)
}
onClose()
}, [item, isFirstInQueue, setEditingItem, onClose])

const handleSave = useCallback(() => {
const trimmedMessage = message.trim()
if (!trimmedMessage || !item) {
handleClose()
return
}

// Only update if changed
if (trimmedMessage !== item.message) {
updateQueueItem(subChatId, item.id, { message: trimmedMessage })
}

// Clear editing state
if (isFirstInQueue) {
setEditingItem(item.id, false)
}
onClose()
}, [message, item, subChatId, updateQueueItem, isFirstInQueue, setEditingItem, onClose, handleClose])

useEffect(() => {
if (!isOpen) return

const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault()
handleClose()
}
// Cmd/Ctrl + Enter to save
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
handleSave()
}
}

document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [isOpen, handleClose, handleSave])

if (!mounted) return null

const portalTarget = typeof document !== "undefined" ? document.body : null
if (!portalTarget) return null

const hasAttachments =
(item?.images && item.images.length > 0) ||
(item?.files && item.files.length > 0) ||
(item?.textContexts && item.textContexts.length > 0) ||
(item?.diffTextContexts && item.diffTextContexts.length > 0)

const attachmentCount =
(item?.images?.length || 0) +
(item?.files?.length || 0) +
(item?.textContexts?.length || 0) +
(item?.diffTextContexts?.length || 0)

return createPortal(
<AnimatePresence mode="wait" initial={false}>
{isOpen && item && (
<>
{/* Overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.18, ease: EASING_CURVE },
}}
exit={{
opacity: 0,
pointerEvents: "none" as const,
transition: { duration: 0.15, ease: EASING_CURVE },
}}
className="fixed inset-0 z-[45] bg-black/25"
onClick={handleClose}
style={{ pointerEvents: "auto" }}
data-modal="edit-queued-message"
/>

{/* Main Dialog */}
<div className="fixed top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] z-[46] pointer-events-none">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
transition={{ duration: 0.2, ease: EASING_CURVE }}
onAnimationComplete={handleAnimationComplete}
className="w-[90vw] max-w-[500px] pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-background rounded-2xl border shadow-2xl overflow-hidden" data-canvas-dialog>
<div className="p-6">
<h2 className="text-xl font-semibold mb-1">
Edit queued message
</h2>
{isFirstInQueue && (
<p className="text-sm text-muted-foreground mb-4">
Queue processing is paused while editing
</p>
)}
{!isFirstInQueue && (
<p className="text-sm text-muted-foreground mb-4">
Edit the message before it's sent
</p>
)}

{/* Textarea for message */}
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Enter your message..."
className="w-full min-h-[120px] max-h-[300px] text-sm resize-y"
/>

{/* Attachment info */}
{hasAttachments && (
<p className="text-xs text-muted-foreground mt-2">
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"} will be included
</p>
)}
</div>

{/* Footer with buttons */}
<div className="bg-muted p-4 flex justify-between border-t border-border rounded-b-xl">
<Button
onClick={handleClose}
variant="ghost"
className="rounded-md"
>
Cancel
</Button>
<Button
onClick={handleSave}
variant="default"
disabled={!message.trim()}
className="rounded-md"
>
Save
</Button>
</div>
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>,
portalTarget,
)
}
20 changes: 20 additions & 0 deletions src/renderer/features/agents/components/queue-processor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export function QueueProcessor() {
return
}

// Check if the first item is being edited - pause processing if so
const firstItem = queue[0]
if (firstItem && useMessageQueueStore.getState().isItemEditing(firstItem.id)) {
return
}

// Get the Chat object from agentChatStore
const chat = agentChatStore.get(subChatId)
if (!chat) {
Expand Down Expand Up @@ -164,11 +170,18 @@ export function QueueProcessor() {
// Check all queues and schedule processing for ready sub-chats
const checkAllQueues = () => {
const queues = useMessageQueueStore.getState().queues
const editingItemIds = useMessageQueueStore.getState().editingItemIds

for (const subChatId of Object.keys(queues)) {
const queue = queues[subChatId]
if (!queue || queue.length === 0) continue

// Skip if first item is being edited (pause processing)
const firstItem = queue[0]
if (firstItem && editingItemIds.has(firstItem.id)) {
continue
}

const status = useStreamingStatusStore.getState().getStatus(subChatId)

// Process when ready, or retry on error status
Expand All @@ -194,13 +207,20 @@ export function QueueProcessor() {
() => checkAllQueues()
)

// Subscribe to editing state changes (to resume processing when editing finishes)
const unsubscribeEditing = useMessageQueueStore.subscribe(
(state) => state.editingItemIds,
() => checkAllQueues()
)

// Initial check
checkAllQueues()

// Cleanup
return () => {
unsubscribeQueue()
unsubscribeStatus()
unsubscribeEditing()

// Clear all timers
for (const timer of timersRef.current.values()) {
Expand Down
1 change: 1 addition & 0 deletions src/renderer/features/agents/main/active-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4110,6 +4110,7 @@ const ChatViewInner = memo(function ChatViewInner({
onSendNow={handleSendFromQueue}
isStreaming={isStreaming}
hasStatusCardBelow={changedFilesForSubChat.length > 0}
subChatId={subChatId}
/>
)}
{/* Status card - bottom card, only when there are changed files */}
Expand Down
41 changes: 41 additions & 0 deletions src/renderer/features/agents/stores/message-queue-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ interface MessageQueueState {
// Map: subChatId -> queue items
queues: Record<string, AgentQueueItem[]>

// Track which items are being edited (pauses processing for items at top of queue)
editingItemIds: Set<string>

// Actions
addToQueue: (subChatId: string, item: AgentQueueItem) => void
removeFromQueue: (subChatId: string, itemId: string) => void
Expand All @@ -21,11 +24,17 @@ interface MessageQueueState {
popItem: (subChatId: string, itemId: string) => AgentQueueItem | null
// Add item to front of queue (for error recovery)
prependItem: (subChatId: string, item: AgentQueueItem) => void
// Update a queue item (for editing)
updateQueueItem: (subChatId: string, itemId: string, updates: Partial<Pick<AgentQueueItem, "message" | "images" | "files" | "textContexts" | "diffTextContexts">>) => void
// Track editing state
setEditingItem: (itemId: string, isEditing: boolean) => void
isItemEditing: (itemId: string) => boolean
}

export const useMessageQueueStore = create<MessageQueueState>()(
subscribeWithSelector((set, get) => ({
queues: {},
editingItemIds: new Set<string>(),

addToQueue: (subChatId, item) => {
set((state) => ({
Expand Down Expand Up @@ -92,4 +101,36 @@ export const useMessageQueueStore = create<MessageQueueState>()(
},
}))
},

// Update a queue item (for editing queued messages)
updateQueueItem: (subChatId, itemId, updates) => {
set((state) => {
const currentQueue = state.queues[subChatId] || []
return {
queues: {
...state.queues,
[subChatId]: currentQueue.map((item) =>
item.id === itemId ? { ...item, ...updates } : item
),
},
}
})
},

// Track which item is being edited (prevents processing if at top of queue)
setEditingItem: (itemId, isEditing) => {
set((state) => {
const newEditingIds = new Set(state.editingItemIds)
if (isEditing) {
newEditingIds.add(itemId)
} else {
newEditingIds.delete(itemId)
}
return { editingItemIds: newEditingIds }
})
},

isItemEditing: (itemId) => {
return get().editingItemIds.has(itemId)
},
})))
Loading