From ed18fb4a268e301a1f15fa08d7d1174dba22140a Mon Sep 17 00:00:00 2001 From: twursc Date: Sat, 25 Apr 2026 14:06:40 +0000 Subject: [PATCH] feat: queue task chat messages Co-authored-by: monkeycode-ai --- .../components/console/task/chat-inputbox.tsx | 121 ++++++++++++------ .../pages/console/user/task/task-detail.tsx | 63 ++++++++- 2 files changed, 145 insertions(+), 39 deletions(-) diff --git a/frontend/src/components/console/task/chat-inputbox.tsx b/frontend/src/components/console/task/chat-inputbox.tsx index e4829c5d..9e4cfd58 100644 --- a/frontend/src/components/console/task/chat-inputbox.tsx +++ b/frontend/src/components/console/task/chat-inputbox.tsx @@ -1,6 +1,6 @@ import { useState, useRef } from "react" import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group" -import { IconCommand, IconLoader, IconPlayerStopFilled, IconSend, IconTerminal2 } from "@tabler/icons-react" +import { IconArrowDown, IconArrowUp, IconCommand, IconLoader, IconPlayerStopFilled, IconSend, IconTerminal2, IconTrash } from "@tabler/icons-react" import React from "react" import { VoiceInputButton } from "./voice-input-button" import type { TaskMessageHandlerStatus } from "@/components/console/task/task-message-handler" @@ -10,21 +10,29 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge import { cn } from "@/lib/utils" +export interface QueuedUserInput { + id: string + content: string +} + interface TaskChatInputBoxProps { streamStatus: TaskStreamStatus | TaskMessageHandlerStatus availableCommands: AvailableCommands | null onSend: (content: string) => void sending: boolean queueSize: number + queuedMessages?: QueuedUserInput[] + onMoveQueuedMessage?: (id: string, direction: "up" | "down") => void + onRemoveQueuedMessage?: (id: string) => void executionTimeMs?: number onCancel?: () => void } -export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, sending, queueSize, executionTimeMs = 0, onCancel }: TaskChatInputBoxProps) => { +export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, sending, queueSize, queuedMessages = [], onMoveQueuedMessage, onRemoveQueuedMessage, executionTimeMs = 0, onCancel }: TaskChatInputBoxProps) => { const [content, setContent] = useState('') const [isComposing, setIsComposing] = useState(false) const textareaRef = useRef(null) - const isExecuting = (streamStatus === 'connected' || streamStatus === 'inited') + const isExecuting = (streamStatus === 'connected' || streamStatus === 'executing' || streamStatus === 'inited') const handleSend = () => { if (content.trim() === '') { @@ -40,9 +48,6 @@ export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, send // 处理键盘事件 const handleKeyDown = (e: React.KeyboardEvent) => { - if (isExecuting) { - return - } // 如果正在输入法组合过程中,不触发提交 if (isComposing) { return @@ -63,35 +68,68 @@ export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, send setIsComposing(false) } - const canInput = React.useMemo(() => { - return !sending && !isExecuting && queueSize === 0 - }, [sending, isExecuting, queueSize]) - - const inputDisabled = React.useMemo(() => { - return !canInput - }, [canInput]) - const controlsDisabled = React.useMemo(() => { - return !canInput - }, [canInput]) + return sending || isExecuting || queueSize > 0 + }, [sending, isExecuting, queueSize]) const commandItems = availableCommands?.commands ?? [] const showCommandItems = !isExecuting && commandItems.length > 0 return ( -
+
+ {queuedMessages.length > 0 && ( +
+
待发送队列,从上往下依次发送
+ {queuedMessages.map((message, index) => ( +
+
{message.content}
+ + + +
+ ))} +
+ )} - {!isExecuting && ( - setContent(e.target.value)} - onKeyDown={handleKeyDown} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} /> - )} + setContent(e.target.value)} + onKeyDown={handleKeyDown} + onCompositionStart={handleCompositionStart} + onCompositionEnd={handleCompositionEnd} />
@@ -134,15 +172,26 @@ export const TaskChatInputBox = ({ streamStatus, availableCommands, onSend, send disabled={controlsDisabled} /> )} - + + + )} + - {isExecuting ? : } - {!isExecuting && "发送"} + + {isExecuting || sending || queueSize > 0 ? "排队" : "发送"}
diff --git a/frontend/src/pages/console/user/task/task-detail.tsx b/frontend/src/pages/console/user/task/task-detail.tsx index 68b70845..8492d97e 100644 --- a/frontend/src/pages/console/user/task/task-detail.tsx +++ b/frontend/src/pages/console/user/task/task-detail.tsx @@ -1,7 +1,7 @@ import { ConstsTaskStatus, type DomainProjectTask, type DomainVMPort } from "@/api/Api" import { useBreadcrumbTask } from "@/components/console/breadcrumb-task-context" import { PlanStepsBlock } from "@/components/console/task/chat-panel" -import { TaskChatInputBox } from "@/components/console/task/chat-inputbox" +import { TaskChatInputBox, type QueuedUserInput } from "@/components/console/task/chat-inputbox" import { TaskControlClient } from "@/components/console/task/task-control-client" import { TaskMessageHandler, type TaskMessageHandlerStatus } from "@/components/console/task/task-message-handler" import { MessageItem, type MessageType } from "@/components/console/task/message" @@ -58,6 +58,7 @@ export default function TaskDetailPage() { used: null, }) const [sending, setSending] = React.useState(false) + const [queuedUserInputs, setQueuedUserInputs] = React.useState([]) const [rawHistoryMessages, setRawHistoryMessages] = React.useState([]) const [rawLiveMessages, setRawLiveMessages] = React.useState([]) const [streamConnectionState, setStreamConnectionState] = React.useState("closed") @@ -198,6 +199,7 @@ export default function TaskDetailPage() { const totalTokens = task?.stats?.total_tokens ?? ((task?.stats?.input_tokens ?? 0) + (task?.stats?.output_tokens ?? 0)) const hasContextUsage = contextUsage.size !== null || contextUsage.used !== null const canInput = taskInteractive && !sending && streamStatus !== "connected" && streamStatus !== "inited" + const conversationBusy = sending || streamStatus === "connected" || streamStatus === "inited" || streamConnectionState === "connecting" || streamConnectionState === "reconnecting" const planStreamStatus: TaskStreamStatus = streamStatus === "connected" ? "executing" : streamStatus const contextProgress = contextUsage.size && contextUsage.size > 0 ? Math.min(Math.max((contextUsage.used ?? 0) / contextUsage.size, 0), 1) @@ -379,6 +381,7 @@ export default function TaskDetailPage() { version: 0, }) setSending(false) + setQueuedUserInputs([]) setRawHistoryMessages([]) setRawLiveMessages([]) setStreamConnectionState("closed") @@ -574,10 +577,61 @@ export default function TaskDetailPage() { fetchPortForwards() }, [fetchPortForwards, previewDialogOpen, taskInteractive]) - const handleSend = React.useCallback((content: string) => { + const sendUserInputNow = React.useCallback((content: string) => { if (!taskId) return Promise.resolve(false) return connectStreamClient("new", content) }, [connectStreamClient, taskId]) + + const enqueueUserInput = React.useCallback((content: string) => { + const trimmedContent = content.trim() + if (!trimmedContent) return + + setQueuedUserInputs((prev) => [ + ...prev, + { + id: `${Date.now()}-${Math.random()}`, + content, + }, + ]) + }, []) + + const handleSend = React.useCallback((content: string) => { + if (!taskId) return Promise.resolve(false) + if (conversationBusy || queuedUserInputs.length > 0) { + enqueueUserInput(content) + return Promise.resolve(true) + } + + return sendUserInputNow(content) + }, [conversationBusy, enqueueUserInput, queuedUserInputs.length, sendUserInputNow, taskId]) + + const moveQueuedUserInput = React.useCallback((id: string, direction: "up" | "down") => { + setQueuedUserInputs((prev) => { + const index = prev.findIndex((message) => message.id === id) + if (index === -1) return prev + + const nextIndex = direction === "up" ? index - 1 : index + 1 + if (nextIndex < 0 || nextIndex >= prev.length) return prev + + const next = [...prev] + const current = next[index] + next[index] = next[nextIndex] + next[nextIndex] = current + return next + }) + }, []) + + const removeQueuedUserInput = React.useCallback((id: string) => { + setQueuedUserInputs((prev) => prev.filter((message) => message.id !== id)) + }, []) + + React.useEffect(() => { + if (!taskInteractive || conversationBusy || queuedUserInputs.length === 0) return + + const [nextMessage] = queuedUserInputs + setQueuedUserInputs((prev) => prev.slice(1)) + void sendUserInputNow(nextMessage.content) + }, [conversationBusy, queuedUserInputs, sendUserInputNow, taskInteractive]) const messages = React.useMemo(() => { const enhanceErrorMessage = (message: MessageType) => { if (message.type !== "error_message") { @@ -1042,7 +1096,10 @@ export default function TaskDetailPage() { onSend={handleSend} onCancel={handleCancel} sending={sending} - queueSize={0} + queueSize={queuedUserInputs.length} + queuedMessages={queuedUserInputs} + onMoveQueuedMessage={moveQueuedUserInput} + onRemoveQueuedMessage={removeQueuedUserInput} executionTimeMs={timeCost} /> ) : (