支持会话响应时添加队列消息
This commit is contained in:
@@ -9,7 +9,9 @@ import type {
|
||||
SessionModeInfo,
|
||||
AvailableCommandInfo,
|
||||
} from "@/lib/types"
|
||||
import type { QueuedMessage } from "@/hooks/use-message-queue"
|
||||
import { MessageInput } from "@/components/chat/message-input"
|
||||
import { MessageQueueDisplay } from "@/components/chat/message-queue-display"
|
||||
|
||||
interface ChatInputProps {
|
||||
status: ConnectionStatus | null
|
||||
@@ -29,6 +31,16 @@ interface ChatInputProps {
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
isActive?: boolean
|
||||
queue?: QueuedMessage[]
|
||||
onEnqueue?: (draft: PromptDraft, modeId: string | null) => void
|
||||
onQueueReorder?: (items: QueuedMessage[]) => void
|
||||
onQueueEdit?: (id: string) => void
|
||||
onQueueDelete?: (id: string) => void
|
||||
editingItemId?: string | null
|
||||
editingDraftText?: string | null
|
||||
isEditingQueueItem?: boolean
|
||||
onSaveQueueEdit?: (draft: PromptDraft) => void
|
||||
onCancelQueueEdit?: () => void
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -49,6 +61,16 @@ export function ChatInput({
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
isActive,
|
||||
queue,
|
||||
onEnqueue,
|
||||
onQueueReorder,
|
||||
onQueueEdit,
|
||||
onQueueDelete,
|
||||
editingItemId,
|
||||
editingDraftText,
|
||||
isEditingQueueItem,
|
||||
onSaveQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
}: ChatInputProps) {
|
||||
const t = useTranslations("Folder.chat.chatInput")
|
||||
const isConnected = status === "connected"
|
||||
@@ -57,12 +79,25 @@ export function ChatInput({
|
||||
|
||||
return (
|
||||
<div className="p-4 pt-0">
|
||||
{queue &&
|
||||
queue.length > 0 &&
|
||||
onQueueReorder &&
|
||||
onQueueEdit &&
|
||||
onQueueDelete && (
|
||||
<MessageQueueDisplay
|
||||
queue={queue}
|
||||
onReorder={onQueueReorder}
|
||||
onEdit={onQueueEdit}
|
||||
onDelete={onQueueDelete}
|
||||
editingItemId={editingItemId ?? null}
|
||||
/>
|
||||
)}
|
||||
<MessageInput
|
||||
onSend={onSend}
|
||||
promptCapabilities={promptCapabilities}
|
||||
onFocus={onFocus}
|
||||
defaultPath={defaultPath}
|
||||
disabled={!isConnected}
|
||||
disabled={!isConnected && !isPrompting}
|
||||
isPrompting={isPrompting}
|
||||
onCancel={onCancel}
|
||||
modes={modes}
|
||||
@@ -76,6 +111,11 @@ export function ChatInput({
|
||||
attachmentTabId={attachmentTabId}
|
||||
draftStorageKey={draftStorageKey}
|
||||
isActive={isActive}
|
||||
onEnqueue={onEnqueue}
|
||||
editingDraftText={editingDraftText}
|
||||
isEditingQueueItem={isEditingQueueItem}
|
||||
onSaveQueueEdit={onSaveQueueEdit}
|
||||
onCancelQueueEdit={onCancelQueueEdit}
|
||||
placeholder={
|
||||
isConnecting
|
||||
? t("connecting")
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
PendingPermission,
|
||||
PendingQuestion,
|
||||
} from "@/contexts/acp-connections-context"
|
||||
import type { QueuedMessage } from "@/hooks/use-message-queue"
|
||||
import { ChatInput } from "@/components/chat/chat-input"
|
||||
import { PermissionDialog } from "@/components/chat/permission-dialog"
|
||||
import { QuestionDialog } from "@/components/chat/question-dialog"
|
||||
@@ -40,6 +41,16 @@ interface ConversationShellProps {
|
||||
draftStorageKey?: string | null
|
||||
hideInput?: boolean
|
||||
isActive?: boolean
|
||||
queue?: QueuedMessage[]
|
||||
onEnqueue?: (draft: PromptDraft, modeId: string | null) => void
|
||||
onQueueReorder?: (items: QueuedMessage[]) => void
|
||||
onQueueEdit?: (id: string) => void
|
||||
onQueueDelete?: (id: string) => void
|
||||
editingItemId?: string | null
|
||||
editingDraftText?: string | null
|
||||
isEditingQueueItem?: boolean
|
||||
onSaveQueueEdit?: (draft: PromptDraft) => void
|
||||
onCancelQueueEdit?: () => void
|
||||
}
|
||||
|
||||
export function ConversationShell({
|
||||
@@ -67,6 +78,16 @@ export function ConversationShell({
|
||||
draftStorageKey,
|
||||
hideInput = false,
|
||||
isActive,
|
||||
queue,
|
||||
onEnqueue,
|
||||
onQueueReorder,
|
||||
onQueueEdit,
|
||||
onQueueDelete,
|
||||
editingItemId,
|
||||
editingDraftText,
|
||||
isEditingQueueItem,
|
||||
onSaveQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
}: ConversationShellProps) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
@@ -98,6 +119,16 @@ export function ConversationShell({
|
||||
attachmentTabId={attachmentTabId}
|
||||
draftStorageKey={draftStorageKey}
|
||||
isActive={isActive}
|
||||
queue={queue}
|
||||
onEnqueue={onEnqueue}
|
||||
onQueueReorder={onQueueReorder}
|
||||
onQueueEdit={onQueueEdit}
|
||||
onQueueDelete={onQueueDelete}
|
||||
editingItemId={editingItemId}
|
||||
editingDraftText={editingDraftText}
|
||||
isEditingQueueItem={isEditingQueueItem}
|
||||
onSaveQueueEdit={onSaveQueueEdit}
|
||||
onCancelQueueEdit={onCancelQueueEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -13,7 +13,16 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Ellipsis, FileSearch, Plus, Send, Square, X } from "lucide-react"
|
||||
import {
|
||||
Check,
|
||||
Ellipsis,
|
||||
FileSearch,
|
||||
ListPlus,
|
||||
Plus,
|
||||
Send,
|
||||
Square,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
|
||||
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
|
||||
@@ -62,6 +71,11 @@ interface MessageInputProps {
|
||||
attachmentTabId?: string | null
|
||||
draftStorageKey?: string | null
|
||||
isActive?: boolean
|
||||
onEnqueue?: (draft: PromptDraft, modeId: string | null) => void
|
||||
editingDraftText?: string | null
|
||||
isEditingQueueItem?: boolean
|
||||
onSaveQueueEdit?: (draft: PromptDraft) => void
|
||||
onCancelQueueEdit?: () => void
|
||||
}
|
||||
|
||||
interface ResourceInputAttachment {
|
||||
@@ -261,8 +275,14 @@ export function MessageInput({
|
||||
attachmentTabId,
|
||||
draftStorageKey,
|
||||
isActive = false,
|
||||
onEnqueue,
|
||||
editingDraftText,
|
||||
isEditingQueueItem = false,
|
||||
onSaveQueueEdit,
|
||||
onCancelQueueEdit,
|
||||
}: MessageInputProps) {
|
||||
const t = useTranslations("Folder.chat.messageInput")
|
||||
const tQueue = useTranslations("Folder.chat.messageQueue")
|
||||
const { shortcuts } = useShortcutSettings()
|
||||
const effectiveDraftStorageKey = draftStorageKey ?? attachmentTabId ?? null
|
||||
const resolvedPlaceholder = placeholder ?? t("askAnything")
|
||||
@@ -302,6 +322,24 @@ export function MessageInput({
|
||||
isPromptingRef.current = isPrompting
|
||||
}, [isPrompting])
|
||||
|
||||
// Load external draft text when editing a queue item
|
||||
const prevEditingDraftRef = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (
|
||||
isEditingQueueItem &&
|
||||
editingDraftText != null &&
|
||||
editingDraftText !== prevEditingDraftRef.current
|
||||
) {
|
||||
prevEditingDraftRef.current = editingDraftText
|
||||
setText(editingDraftText)
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus()
|
||||
})
|
||||
} else if (!isEditingQueueItem) {
|
||||
prevEditingDraftRef.current = null
|
||||
}
|
||||
}, [isEditingQueueItem, editingDraftText])
|
||||
|
||||
const setDragActiveIfChanged = useCallback((next: boolean) => {
|
||||
if (dragActiveRef.current === next) return
|
||||
dragActiveRef.current = next
|
||||
@@ -309,9 +347,9 @@ export function MessageInput({
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!effectiveDraftStorageKey) return
|
||||
if (!effectiveDraftStorageKey || isEditingQueueItem) return
|
||||
saveMessageInputDraft(effectiveDraftStorageKey, text)
|
||||
}, [effectiveDraftStorageKey, text])
|
||||
}, [effectiveDraftStorageKey, text, isEditingQueueItem])
|
||||
|
||||
const availableModes = useMemo(() => modes ?? [], [modes])
|
||||
const availableConfigOptions = useMemo(
|
||||
@@ -653,7 +691,7 @@ export function MessageInput({
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(event: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (disabled || isPrompting) return
|
||||
if (disabled) return
|
||||
const files = Array.from(event.clipboardData?.files ?? [])
|
||||
if (files.length === 0) return
|
||||
event.preventDefault()
|
||||
@@ -661,7 +699,7 @@ export function MessageInput({
|
||||
console.error("[MessageInput] paste files failed:", error)
|
||||
})
|
||||
},
|
||||
[appendFilesFromInput, disabled, isPrompting]
|
||||
[appendFilesFromInput, disabled]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -763,15 +801,13 @@ export function MessageInput({
|
||||
if (payload.type === "drop") {
|
||||
setDragActiveIfChanged(false)
|
||||
if (Date.now() - lastDomDropAtRef.current < 250) return
|
||||
if (!inside || disabledRef.current || isPromptingRef.current) return
|
||||
if (!inside || disabledRef.current) return
|
||||
void appendPathsFromDropRef.current(payload.paths).catch((error) => {
|
||||
console.error("[MessageInput] drag drop paths failed:", error)
|
||||
})
|
||||
return
|
||||
}
|
||||
setDragActiveIfChanged(
|
||||
inside && !disabledRef.current && !isPromptingRef.current
|
||||
)
|
||||
setDragActiveIfChanged(inside && !disabledRef.current)
|
||||
}
|
||||
|
||||
const setup = async () => {
|
||||
@@ -843,9 +879,9 @@ export function MessageInput({
|
||||
setAttachments((prev) => prev.filter((item) => item.id !== id))
|
||||
}, [])
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const buildDraft = useCallback((): PromptDraft | null => {
|
||||
const trimmed = textRef.current.trim()
|
||||
if (!trimmed && attachments.length === 0) return
|
||||
if (!trimmed && attachments.length === 0) return null
|
||||
|
||||
const blocks: PromptInputBlock[] = []
|
||||
if (trimmed) {
|
||||
@@ -883,14 +919,41 @@ export function MessageInput({
|
||||
const displayText =
|
||||
trimmed ||
|
||||
`Attached ${attachments.length} attachment${attachments.length > 1 ? "s" : ""}`
|
||||
onSend({ blocks, displayText }, showModeSelector ? effectiveModeId : null)
|
||||
return { blocks, displayText }
|
||||
}, [attachments])
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const draft = buildDraft()
|
||||
if (!draft) return
|
||||
|
||||
// Edit mode: save back to queue item
|
||||
if (isEditingQueueItem && onSaveQueueEdit) {
|
||||
onSaveQueueEdit(draft)
|
||||
setText("")
|
||||
setAttachments([])
|
||||
return
|
||||
}
|
||||
|
||||
// Prompting mode: enqueue instead of sending
|
||||
if (isPrompting && onEnqueue) {
|
||||
onEnqueue(draft, showModeSelector ? effectiveModeId : null)
|
||||
setText("")
|
||||
setAttachments([])
|
||||
return
|
||||
}
|
||||
|
||||
onSend(draft, showModeSelector ? effectiveModeId : null)
|
||||
if (effectiveDraftStorageKey) {
|
||||
clearMessageInputDraft(effectiveDraftStorageKey)
|
||||
}
|
||||
setText("")
|
||||
setAttachments([])
|
||||
}, [
|
||||
attachments,
|
||||
buildDraft,
|
||||
isEditingQueueItem,
|
||||
isPrompting,
|
||||
onSaveQueueEdit,
|
||||
onEnqueue,
|
||||
onSend,
|
||||
effectiveModeId,
|
||||
showModeSelector,
|
||||
@@ -935,9 +998,15 @@ export function MessageInput({
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditingQueueItem && e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
onCancelQueueEdit?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (matchShortcutEvent(e, shortcuts.send_message)) {
|
||||
e.preventDefault()
|
||||
if (!disabled) handleSend()
|
||||
if (!disabled || isPrompting || isEditingQueueItem) handleSend()
|
||||
} else if (matchShortcutEvent(e, shortcuts.newline_in_message)) {
|
||||
e.preventDefault()
|
||||
const textarea = e.currentTarget as HTMLTextAreaElement
|
||||
@@ -953,6 +1022,9 @@ export function MessageInput({
|
||||
},
|
||||
[
|
||||
disabled,
|
||||
isPrompting,
|
||||
isEditingQueueItem,
|
||||
onCancelQueueEdit,
|
||||
handleSend,
|
||||
shortcuts,
|
||||
slashMenuOpen,
|
||||
@@ -966,11 +1038,11 @@ export function MessageInput({
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!hasDragFiles(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
if (!disabled && !isPrompting) {
|
||||
if (!disabled) {
|
||||
setDragActiveIfChanged(true)
|
||||
}
|
||||
},
|
||||
[disabled, isPrompting, setDragActiveIfChanged]
|
||||
[disabled, setDragActiveIfChanged]
|
||||
)
|
||||
|
||||
const handleContainerDragLeave = useCallback(
|
||||
@@ -994,7 +1066,7 @@ export function MessageInput({
|
||||
event.preventDefault()
|
||||
lastDomDropAtRef.current = Date.now()
|
||||
setDragActiveIfChanged(false)
|
||||
if (disabled || isPrompting) return
|
||||
if (disabled) return
|
||||
const files = Array.from(event.dataTransfer.files ?? [])
|
||||
if (files.length > 0) {
|
||||
void appendFilesFromInput(files).catch((error) => {
|
||||
@@ -1002,7 +1074,7 @@ export function MessageInput({
|
||||
})
|
||||
}
|
||||
},
|
||||
[appendFilesFromInput, disabled, isPrompting, setDragActiveIfChanged]
|
||||
[appendFilesFromInput, disabled, setDragActiveIfChanged]
|
||||
)
|
||||
|
||||
const hasImageAttachments = imageAttachments.length > 0
|
||||
@@ -1016,7 +1088,7 @@ export function MessageInput({
|
||||
? "pt-10"
|
||||
: "pt-3"
|
||||
const bottomPaddingClass = "pb-10"
|
||||
const showDragActive = isDragActive && !disabled && !isPrompting
|
||||
const showDragActive = isDragActive && !disabled
|
||||
|
||||
const selectorItems = (
|
||||
<>
|
||||
@@ -1143,7 +1215,7 @@ export function MessageInput({
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
onClick={handlePickFiles}
|
||||
disabled={disabled || isPrompting}
|
||||
disabled={disabled}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
@@ -1175,16 +1247,47 @@ export function MessageInput({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isPrompting && onCancel ? (
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute right-2 bottom-2"
|
||||
title={t("cancel")}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
{isEditingQueueItem ? (
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-1">
|
||||
<Button
|
||||
onClick={onCancelQueueEdit}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title={tQueue("cancelEdit")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!hasSendableContent}
|
||||
size="icon"
|
||||
title={tQueue("saveEdit")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : isPrompting && onCancel ? (
|
||||
<div className="absolute right-2 bottom-2 flex items-center gap-1">
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!hasSendableContent}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
title={tQueue("addToQueue")}
|
||||
>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
title={t("cancel")}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
|
||||
121
src/components/chat/message-queue-display.tsx
Normal file
121
src/components/chat/message-queue-display.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, type PointerEvent } from "react"
|
||||
import { Reorder, useDragControls } from "motion/react"
|
||||
import { GripVertical, Pencil, X } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { QueuedMessage } from "@/hooks/use-message-queue"
|
||||
|
||||
interface MessageQueueDisplayProps {
|
||||
queue: QueuedMessage[]
|
||||
onReorder: (items: QueuedMessage[]) => void
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
editingItemId: string | null
|
||||
}
|
||||
|
||||
interface QueueItemProps {
|
||||
item: QueuedMessage
|
||||
index: number
|
||||
isEditing: boolean
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
function QueueItem({
|
||||
item,
|
||||
index,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: QueueItemProps) {
|
||||
const t = useTranslations("Folder.chat.messageQueue")
|
||||
const dragControls = useDragControls()
|
||||
|
||||
const startDrag = useCallback(
|
||||
(event: PointerEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragControls.start(event)
|
||||
},
|
||||
[dragControls]
|
||||
)
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="div"
|
||||
value={item}
|
||||
dragListener={false}
|
||||
dragControls={dragControls}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md border px-1.5 py-1 text-[10px] leading-none select-none [text-box-trim:both] [text-box-edge:cap_alphabetic]",
|
||||
"bg-muted/40 border-border/70",
|
||||
isEditing && "border-primary/50 bg-primary/5"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab touch-none active:cursor-grabbing p-0"
|
||||
onPointerDown={startDrag}
|
||||
>
|
||||
<GripVertical className="h-3 w-3 text-muted-foreground/60" />
|
||||
</button>
|
||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground/70">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-foreground/80">
|
||||
{item.draft.displayText}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(item.id)}
|
||||
className="shrink-0 rounded-sm p-0.5 hover:bg-muted-foreground/15 text-muted-foreground"
|
||||
title={t("editItem")}
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(item.id)}
|
||||
className="shrink-0 rounded-sm p-0.5 hover:bg-muted-foreground/15 text-muted-foreground"
|
||||
title={t("deleteItem")}
|
||||
>
|
||||
<X className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageQueueDisplay({
|
||||
queue,
|
||||
onReorder,
|
||||
onEdit,
|
||||
onDelete,
|
||||
editingItemId,
|
||||
}: MessageQueueDisplayProps) {
|
||||
if (queue.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="max-h-28 overflow-y-auto pb-1">
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={queue}
|
||||
onReorder={onReorder}
|
||||
className="flex flex-col gap-0.5"
|
||||
>
|
||||
{queue.map((item, index) => (
|
||||
<QueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
isEditing={editingItemId === item.id}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { useTabContext } from "@/contexts/tab-context"
|
||||
import { useSessionStats } from "@/contexts/session-stats-context"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useConnectionLifecycle } from "@/hooks/use-connection-lifecycle"
|
||||
import { useMessageQueue, type QueuedMessage } from "@/hooks/use-message-queue"
|
||||
import { MessageListView } from "@/components/message/message-list-view"
|
||||
import { ConversationShell } from "@/components/chat/conversation-shell"
|
||||
import { AgentSelector } from "@/components/chat/agent-selector"
|
||||
@@ -254,6 +255,18 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
disconnect: connDisconnect,
|
||||
sessionId: connSessionId,
|
||||
} = conn
|
||||
const messageQueue = useMessageQueue()
|
||||
const {
|
||||
queue: msgQueue,
|
||||
enqueue: mqEnqueue,
|
||||
dequeue: mqDequeue,
|
||||
remove: mqRemove,
|
||||
reorder: mqReorder,
|
||||
updateItem: mqUpdateItem,
|
||||
editingItemId: mqEditingItemId,
|
||||
startEditing: mqStartEditing,
|
||||
cancelEditing: mqCancelEditing,
|
||||
} = messageQueue
|
||||
const connStatusRef = useRef(connStatus)
|
||||
useEffect(() => {
|
||||
connStatusRef.current = connStatus
|
||||
@@ -329,6 +342,32 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
syncTurnMetadata,
|
||||
])
|
||||
|
||||
// Auto-send queued messages when agent finishes responding.
|
||||
// Refs are synced via useEffect; the auto-send effect is declared
|
||||
// AFTER completeTurn so React runs it second.
|
||||
const autoSendQueueRef = useRef<() => QueuedMessage | undefined>(mqDequeue)
|
||||
useEffect(() => {
|
||||
autoSendQueueRef.current = mqDequeue
|
||||
}, [mqDequeue])
|
||||
const handleSendRef = useRef<
|
||||
(draft: PromptDraft, modeId?: string | null) => void
|
||||
>(() => {})
|
||||
|
||||
const prevAutoSendStatusRef = useRef(connStatus)
|
||||
useEffect(() => {
|
||||
const wasPrompting = prevAutoSendStatusRef.current === "prompting"
|
||||
prevAutoSendStatusRef.current = connStatus
|
||||
if (!wasPrompting || connStatus !== "connected") return
|
||||
|
||||
// Use queueMicrotask to ensure completeTurn effect has fully committed
|
||||
queueMicrotask(() => {
|
||||
const next = autoSendQueueRef.current()
|
||||
if (next) {
|
||||
handleSendRef.current(next.draft, next.modeId)
|
||||
}
|
||||
})
|
||||
}, [connStatus])
|
||||
|
||||
useEffect(() => {
|
||||
// Only sync non-null liveMessage updates to state. When conn.liveMessage
|
||||
// goes null (agent finished streaming), don't clear state.liveMessage —
|
||||
@@ -545,6 +584,11 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
]
|
||||
)
|
||||
|
||||
// Sync handleSend ref for auto-send effect (declared before handleSend)
|
||||
useEffect(() => {
|
||||
handleSendRef.current = handleSend
|
||||
}, [handleSend])
|
||||
|
||||
const handleOpenAgentsSettings = useCallback(() => {
|
||||
openSettingsWindow("agents", { agentType: selectedAgent }).catch((err) => {
|
||||
console.error(
|
||||
@@ -615,6 +659,33 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
]
|
||||
)
|
||||
|
||||
// Queue edit flow: derive editing draft text from queue state
|
||||
const editingQueueDraftText = useMemo(() => {
|
||||
if (!mqEditingItemId) return null
|
||||
const item = msgQueue.find((m) => m.id === mqEditingItemId)
|
||||
return item?.draft.displayText ?? null
|
||||
}, [mqEditingItemId, msgQueue])
|
||||
|
||||
const handleQueueEdit = useCallback(
|
||||
(id: string) => {
|
||||
mqStartEditing(id)
|
||||
},
|
||||
[mqStartEditing]
|
||||
)
|
||||
|
||||
const handleQueueCancelEdit = useCallback(() => {
|
||||
mqCancelEditing()
|
||||
}, [mqCancelEditing])
|
||||
|
||||
const handleSaveQueueEdit = useCallback(
|
||||
(draft: PromptDraft) => {
|
||||
if (mqEditingItemId) {
|
||||
mqUpdateItem(mqEditingItemId, draft)
|
||||
}
|
||||
},
|
||||
[mqEditingItemId, mqUpdateItem]
|
||||
)
|
||||
|
||||
const showDraftHeader = !hasPersistedConversation && !hasSentMessage
|
||||
const isWelcomeMode = showDraftHeader
|
||||
|
||||
@@ -657,6 +728,16 @@ const ConversationTabView = memo(function ConversationTabView({
|
||||
draftStorageKey={draftStorageKey}
|
||||
hideInput={isWelcomeMode}
|
||||
isActive={isActive}
|
||||
queue={msgQueue}
|
||||
onEnqueue={mqEnqueue}
|
||||
onQueueReorder={mqReorder}
|
||||
onQueueEdit={handleQueueEdit}
|
||||
onQueueDelete={mqRemove}
|
||||
editingItemId={mqEditingItemId}
|
||||
editingDraftText={editingQueueDraftText}
|
||||
isEditingQueueItem={mqEditingItemId != null}
|
||||
onSaveQueueEdit={handleSaveQueueEdit}
|
||||
onCancelQueueEdit={handleQueueCancelEdit}
|
||||
>
|
||||
{isWelcomeMode ? (
|
||||
<div className="flex h-full min-h-0 flex-col items-center justify-center">
|
||||
|
||||
89
src/hooks/use-message-queue.ts
Normal file
89
src/hooks/use-message-queue.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { PromptDraft } from "@/lib/types"
|
||||
|
||||
export interface QueuedMessage {
|
||||
id: string
|
||||
draft: PromptDraft
|
||||
modeId: string | null
|
||||
}
|
||||
|
||||
export interface UseMessageQueueReturn {
|
||||
queue: QueuedMessage[]
|
||||
enqueue: (draft: PromptDraft, modeId: string | null) => void
|
||||
dequeue: () => QueuedMessage | undefined
|
||||
remove: (id: string) => void
|
||||
reorder: (items: QueuedMessage[]) => void
|
||||
updateItem: (id: string, draft: PromptDraft) => void
|
||||
editingItemId: string | null
|
||||
startEditing: (id: string) => void
|
||||
cancelEditing: () => void
|
||||
}
|
||||
|
||||
export function useMessageQueue(): UseMessageQueueReturn {
|
||||
const [queue, setQueue] = useState<QueuedMessage[]>([])
|
||||
const [editingItemId, setEditingItemId] = useState<string | null>(null)
|
||||
const queueRef = useRef(queue)
|
||||
useEffect(() => {
|
||||
queueRef.current = queue
|
||||
}, [queue])
|
||||
|
||||
const enqueue = useCallback((draft: PromptDraft, modeId: string | null) => {
|
||||
const item: QueuedMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
draft,
|
||||
modeId,
|
||||
}
|
||||
setQueue((prev) => [...prev, item])
|
||||
}, [])
|
||||
|
||||
const dequeue = useCallback((): QueuedMessage | undefined => {
|
||||
const current = queueRef.current
|
||||
if (current.length === 0) return undefined
|
||||
const first = current[0]
|
||||
setQueue((prev) => prev.slice(1))
|
||||
return first
|
||||
}, [])
|
||||
|
||||
const remove = useCallback(
|
||||
(id: string) => {
|
||||
if (editingItemId === id) {
|
||||
setEditingItemId(null)
|
||||
}
|
||||
setQueue((prev) => prev.filter((item) => item.id !== id))
|
||||
},
|
||||
[editingItemId]
|
||||
)
|
||||
|
||||
const reorder = useCallback((items: QueuedMessage[]) => {
|
||||
setQueue(items)
|
||||
}, [])
|
||||
|
||||
const updateItem = useCallback((id: string, draft: PromptDraft) => {
|
||||
setQueue((prev) =>
|
||||
prev.map((item) => (item.id === id ? { ...item, draft } : item))
|
||||
)
|
||||
setEditingItemId(null)
|
||||
}, [])
|
||||
|
||||
const startEditing = useCallback((id: string) => {
|
||||
setEditingItemId(id)
|
||||
}, [])
|
||||
|
||||
const cancelEditing = useCallback(() => {
|
||||
setEditingItemId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
queue,
|
||||
enqueue,
|
||||
dequeue,
|
||||
remove,
|
||||
reorder,
|
||||
updateItem,
|
||||
editingItemId,
|
||||
startEditing,
|
||||
cancelEditing,
|
||||
}
|
||||
}
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "إلغاء",
|
||||
"send": "إرسال"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "إضافة للقائمة",
|
||||
"saveEdit": "حفظ",
|
||||
"cancelEdit": "إلغاء التعديل",
|
||||
"editItem": "تعديل",
|
||||
"deleteItem": "حذف"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "الإعدادات > الوكلاء",
|
||||
"autoConnectFallback": "انقر لفتح {path} وإدارة التثبيت.",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "Abbrechen",
|
||||
"send": "Senden"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Zur Warteschlange",
|
||||
"saveEdit": "Speichern",
|
||||
"cancelEdit": "Bearbeitung abbrechen",
|
||||
"editItem": "Bearbeiten",
|
||||
"deleteItem": "Entfernen"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "Einstellungen > Agenten",
|
||||
"autoConnectFallback": "Klicken Sie, um {path} zu öffnen und die Installation zu verwalten.",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "Cancel",
|
||||
"send": "Send"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Queue message",
|
||||
"saveEdit": "Save",
|
||||
"cancelEdit": "Cancel edit",
|
||||
"editItem": "Edit",
|
||||
"deleteItem": "Remove"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "Settings > Agents",
|
||||
"autoConnectFallback": "Click to open {path} and manage installation.",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "Cancelar",
|
||||
"send": "Enviar"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Agregar a la cola",
|
||||
"saveEdit": "Guardar",
|
||||
"cancelEdit": "Cancelar edición",
|
||||
"editItem": "Editar",
|
||||
"deleteItem": "Eliminar"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "Ajustes > Agentes",
|
||||
"autoConnectFallback": "Haz clic para abrir {path} y gestionar la instalación.",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "Annuler",
|
||||
"send": "Envoyer"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Mettre en file",
|
||||
"saveEdit": "Enregistrer",
|
||||
"cancelEdit": "Annuler la modification",
|
||||
"editItem": "Modifier",
|
||||
"deleteItem": "Supprimer"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "Paramètres > Agents",
|
||||
"autoConnectFallback": "Cliquez pour ouvrir {path} et gérer l'installation.",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "キャンセル",
|
||||
"send": "送信"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "キューに追加",
|
||||
"saveEdit": "保存",
|
||||
"cancelEdit": "編集をキャンセル",
|
||||
"editItem": "編集",
|
||||
"deleteItem": "削除"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "設定 > エージェント",
|
||||
"autoConnectFallback": "{path} を開いてインストールを管理してください。",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "취소",
|
||||
"send": "보내기"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "대기열에 추가",
|
||||
"saveEdit": "저장",
|
||||
"cancelEdit": "편집 취소",
|
||||
"editItem": "편집",
|
||||
"deleteItem": "삭제"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "설정 > 에이전트",
|
||||
"autoConnectFallback": "{path}을(를) 열어 설치를 관리하세요.",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "Cancelar",
|
||||
"send": "Enviar"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "Adicionar à fila",
|
||||
"saveEdit": "Salvar",
|
||||
"cancelEdit": "Cancelar edição",
|
||||
"editItem": "Editar",
|
||||
"deleteItem": "Remover"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "Configurações > Agentes",
|
||||
"autoConnectFallback": "Clique para abrir {path} e gerenciar a instalação.",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "取消",
|
||||
"send": "发送"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "加入队列",
|
||||
"saveEdit": "保存",
|
||||
"cancelEdit": "取消编辑",
|
||||
"editItem": "编辑",
|
||||
"deleteItem": "删除"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "设置 > Agents",
|
||||
"autoConnectFallback": "点击前往 {path} 管理安装。",
|
||||
|
||||
@@ -1145,6 +1145,13 @@
|
||||
"cancel": "取消",
|
||||
"send": "傳送"
|
||||
},
|
||||
"messageQueue": {
|
||||
"addToQueue": "加入佇列",
|
||||
"saveEdit": "儲存",
|
||||
"cancelEdit": "取消編輯",
|
||||
"editItem": "編輯",
|
||||
"deleteItem": "刪除"
|
||||
},
|
||||
"welcomeInputPanel": {
|
||||
"agentsSettingsPath": "設定 > Agents",
|
||||
"autoConnectFallback": "點擊前往 {path} 管理安裝。",
|
||||
|
||||
Reference in New Issue
Block a user