diff --git a/apps/web/components/chat/index.tsx b/apps/web/components/chat/index.tsx index c8bfd761..ac92197c 100644 --- a/apps/web/components/chat/index.tsx +++ b/apps/web/components/chat/index.tsx @@ -101,6 +101,15 @@ export function ChatLaunchFab({ ) } +type QueuedChatMessage = { + id: string + text: string + model: ModelId + reasoningEffort: ReasoningEffort +} + +const CHAT_QUEUE_LIMIT = 5 + export function ChatSidebar({ isChatOpen, setIsChatOpen, @@ -140,6 +149,9 @@ export function ChatSidebar({ selectedModelRef.current = selectedModel const reasoningEffortRef = useRef(reasoningEffort) reasoningEffortRef.current = reasoningEffort + const [messageQueue, setMessageQueue] = useState([]) + const queuedDispatchInFlightRef = useRef(false) + const queuedDispatchSawResponseRef = useRef(false) const [copiedMessageId, setCopiedMessageId] = useState(null) const [hoveredMessageId, setHoveredMessageId] = useState(null) const [messageFeedback, setMessageFeedback] = useState< @@ -368,12 +380,35 @@ export function ChatSidebar({ }, []) const handleSend = () => { - if (!input.trim() || status === "submitted" || status === "streaming") + const text = input.trim() + if (!text) return + + if (status === "submitted" || status === "streaming") { + if (messageQueue.length >= CHAT_QUEUE_LIMIT) return + + setMessageQueue((prev) => { + if (prev.length >= CHAT_QUEUE_LIMIT) return prev + return [ + ...prev, + { + id: generateId(), + text, + model: selectedModel, + reasoningEffort, + }, + ] + }) + setInput("") + analytics.chatMessageSent({ source: "typed" }) + userJustSentRef.current = true + scrollToBottom() return + } + truncateFromMessageIdRef.current = null if (!threadId) setThreadId(fallbackChatId) analytics.chatMessageSent({ source: "typed" }) - sendMessage({ text: input }) + sendMessage({ text }) setInput("") userJustSentRef.current = true scrollToBottom() @@ -445,6 +480,8 @@ export function ChatSidebar({ pendingRegenerationRef.current = null setRegenerationBaseLength(null) analytics.chatMessageSent({ source: "typed" }) + queuedDispatchInFlightRef.current = true + queuedDispatchSawResponseRef.current = false sendMessage({ text: pending.text }) window.setTimeout(() => { truncateFromMessageIdRef.current = null @@ -513,6 +550,9 @@ export function ChatSidebar({ setThreadId(null) setFallbackChatId(newChatId) setInput("") + setMessageQueue([]) + queuedDispatchInFlightRef.current = false + queuedDispatchSawResponseRef.current = false }, [setThreadId, setMessages]) const fetchThreads = useCallback(async () => { @@ -568,6 +608,9 @@ export function ChatSidebar({ ) setThreadId(id) setPendingThreadLoad({ id, messages: uiMessages }) + setMessageQueue([]) + queuedDispatchInFlightRef.current = false + queuedDispatchSawResponseRef.current = false analytics.chatThreadLoaded({ thread_id: id }) setIsHistoryOpen(false) setConfirmingDeleteId(null) @@ -700,6 +743,8 @@ export function ChatSidebar({ } else { truncateFromMessageIdRef.current = null if (!threadId) setThreadId(fallbackChatId) + queuedDispatchInFlightRef.current = true + queuedDispatchSawResponseRef.current = false sendMessage({ text: queuedMessage }) } onConsumeQueuedMessage?.() @@ -753,6 +798,8 @@ export function ChatSidebar({ const reply = pendingHighlightReplyRef.current pendingHighlightReplyRef.current = null truncateFromMessageIdRef.current = null + queuedDispatchInFlightRef.current = true + queuedDispatchSawResponseRef.current = false sendMessage({ text: reply }) } }, [messages, sendMessage, status]) @@ -764,6 +811,56 @@ export function ChatSidebar({ } }, [queuedMessage]) + useEffect(() => { + const isRespondingNow = status === "submitted" || status === "streaming" + if (isRespondingNow) { + if (queuedDispatchInFlightRef.current) { + queuedDispatchSawResponseRef.current = true + } + return + } + + if (status !== "ready") { + queuedDispatchInFlightRef.current = false + queuedDispatchSawResponseRef.current = false + return + } + + if (queuedDispatchInFlightRef.current) { + if (!queuedDispatchSawResponseRef.current) return + queuedDispatchInFlightRef.current = false + queuedDispatchSawResponseRef.current = false + } + + const nextMessage = messageQueue[0] + if (!nextMessage) return + + queuedDispatchInFlightRef.current = true + queuedDispatchSawResponseRef.current = false + truncateFromMessageIdRef.current = null + pendingSendSettingsRef.current = { + model: nextMessage.model, + reasoningEffort: nextMessage.reasoningEffort, + } + if (!threadId) setThreadId(fallbackChatId) + setMessageQueue((prev) => + prev[0]?.id === nextMessage.id + ? prev.slice(1) + : prev.filter((item) => item.id !== nextMessage.id), + ) + sendMessage({ text: nextMessage.text }) + userJustSentRef.current = true + scrollToBottom() + }, [ + fallbackChatId, + messageQueue, + scrollToBottom, + sendMessage, + setThreadId, + status, + threadId, + ]) + // Scroll to bottom when a new user message is added or a thread is loaded useEffect(() => { const lastMessageId = messages[messages.length - 1]?.id ?? null @@ -882,6 +979,7 @@ export function ChatSidebar({ const isResponding = status === "submitted" || status === "streaming" const showInputStatusStrip = !isStackedInput || isResponding || messages.length > 0 + const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT const chatHistorySheet = ( )} + {messageQueue.length > 0 && ( +
+
+
+
+ {messageQueue.length} +
+

+ Queued messages +

+
+

+ {messageQueue.length}/{CHAT_QUEUE_LIMIT} +

+
+
+ {messageQueue.map((queued, index) => { + const model = modelNames[queued.model] + const reasoningLabel = + queued.reasoningEffort === "thinking" ? "Thinking" : "Instant" + return ( +
+
+ {index + 1} +
+
+

+ {queued.text} +

+

+ {model.name} {model.version} - {reasoningLabel} +

+
+ +
+ ) + })} +
+
+ )} +
void disabled: boolean + disabledTooltip?: string }) { const button = ( ) diff --git a/apps/web/components/chat/reasoning-selector.tsx b/apps/web/components/chat/reasoning-selector.tsx index 223f6f27..7eed41fd 100644 --- a/apps/web/components/chat/reasoning-selector.tsx +++ b/apps/web/components/chat/reasoning-selector.tsx @@ -32,6 +32,8 @@ export function ReasoningSelector({ const selected = reasoningOptions.find((option) => option.id === value) const SelectedIcon = value === "thinking" ? BrainIcon : ZapIcon const selectedLabel = selected?.label ?? "Reasoning" + const selectedItemClass = + "border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]" useEffect(() => { if (!isOpen) return @@ -105,30 +107,30 @@ export function ReasoningSelector({ type="button" onClick={() => handleSelect(option.id)} className={cn( - "flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2.5 text-left transition-colors", + "flex w-full cursor-pointer items-center gap-2.5 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors", isSelected - ? "bg-[#E6E6E6] text-[#101010]" + ? selectedItemClass : "text-white hover:bg-white/10", )} >
{option.label}
{isSelected && ( - + )} )