支持会话响应时添加队列消息

This commit is contained in:
xintaofei
2026-03-12 23:19:24 +08:00
parent 6c26d067fd
commit 1f623c0d6e
16 changed files with 566 additions and 31 deletions

View File

@@ -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")

View File

@@ -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}
/>
)}

View File

@@ -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}

View 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>
)
}

View File

@@ -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">