diff --git a/apps/web/app/(app)/page.tsx b/apps/web/app/(app)/page.tsx index 120bd2b94..17433857b 100644 --- a/apps/web/app/(app)/page.tsx +++ b/apps/web/app/(app)/page.tsx @@ -41,7 +41,7 @@ import { useQuickNoteDraft, } from "@/stores/quick-note-draft" import { analytics } from "@/lib/analytics" -import type { ModelId } from "@/lib/models" +import type { ModelId, ReasoningEffort } from "@/lib/models" import { useDocumentMutations } from "@/hooks/use-document-mutations" import { useQuery, useQueryClient } from "@tanstack/react-query" import { toast } from "sonner" @@ -161,6 +161,8 @@ export default function NewPage() { const [fullscreenInitialContent, setFullscreenInitialContent] = useState("") const [queuedChatSeed, setQueuedChatSeed] = useState(null) const [queuedChatModel, setQueuedChatModel] = useState(null) + const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] = + useState(null) const [queuedChatProject, setQueuedChatProject] = useState( null, ) @@ -490,6 +492,7 @@ export default function NewPage() { setQueuedHighlightContent(highlightContent) setQueuedChatSeed(userReply) setQueuedChatModel(null) + setQueuedChatReasoningEffort(null) setQueuedChatProject(null) setQueuedMessageSource("highlight") void setViewMode("chat") @@ -498,10 +501,16 @@ export default function NewPage() { ) const handleHomeChatStart = useCallback( - (message: string, model: ModelId, projectId: string) => { + ( + message: string, + model: ModelId, + projectId: string, + reasoningEffort: ReasoningEffort, + ) => { setQueuedHighlightContent(null) setQueuedChatSeed(message) setQueuedChatModel(model) + setQueuedChatReasoningEffort(reasoningEffort) setQueuedChatProject(projectId) setQueuedMessageSource("home") void setViewMode("chat") @@ -512,6 +521,7 @@ export default function NewPage() { const consumeQueuedChat = useCallback(() => { setQueuedChatSeed(null) setQueuedChatModel(null) + setQueuedChatReasoningEffort(null) setQueuedChatProject(null) setQueuedHighlightContent(null) setQueuedMessageSource("highlight") @@ -633,6 +643,7 @@ export default function NewPage() { onConsumeQueuedMessage={consumeQueuedChat} queuedMessageSource={queuedMessageSource} initialSelectedModel={queuedChatModel} + initialReasoningEffort={queuedChatReasoningEffort} initialChatProject={queuedChatProject} /> diff --git a/apps/web/components/chat/home-chat-composer.tsx b/apps/web/components/chat/home-chat-composer.tsx index 2f7cda2db..5e78fbb8e 100644 --- a/apps/web/components/chat/home-chat-composer.tsx +++ b/apps/web/components/chat/home-chat-composer.tsx @@ -8,27 +8,54 @@ import { cn } from "@lib/utils" import type { ModelId } from "@/lib/models" import { SpaceSelector } from "@/components/space-selector" import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space" +import { ReasoningSelector } from "./reasoning-selector" +import { getDefaultReasoningEffort, type ReasoningEffort } from "@/lib/models" export function HomeChatComposer({ onStartChat, className, }: { - onStartChat: (message: string, model: ModelId, projectId: string) => void + onStartChat: ( + message: string, + model: ModelId, + projectId: string, + reasoningEffort: ReasoningEffort, + ) => void className?: string }) { const [input, setInput] = useState("") const [selectedModel, setSelectedModel] = useState("gemini-2.5-pro") + const [reasoningEffort, setReasoningEffort] = useState( + getDefaultReasoningEffort("gemini-2.5-pro"), + ) const { selectedProject } = useProject() const [chatSpaceProjects, setChatSpaceProjects] = useState([ AUTO_CHAT_SPACE_ID, ]) + const handleModelChange = useCallback((model: ModelId) => { + setSelectedModel(model) + setReasoningEffort(getDefaultReasoningEffort(model)) + }, []) + const send = useCallback(() => { const t = input.trim() if (!t) return - onStartChat(t, selectedModel, chatSpaceProjects[0] ?? selectedProject) + onStartChat( + t, + selectedModel, + chatSpaceProjects[0] ?? selectedProject, + reasoningEffort, + ) setInput("") - }, [chatSpaceProjects, input, onStartChat, selectedModel, selectedProject]) + }, [ + chatSpaceProjects, + input, + onStartChat, + reasoningEffort, + selectedModel, + selectedProject, + ]) const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -52,9 +79,13 @@ export function HomeChatComposer({ <> + } + ).metadata + return ( + normalizeModelId(metadata?.model) ?? + normalizeModelId(metadata?.responseModel) ?? + null + ) +} + export function ChatSidebar({ isChatOpen, setIsChatOpen: _setIsChatOpen, @@ -102,6 +133,7 @@ export function ChatSidebar({ onConsumeQueuedMessage, queuedMessageSource = "highlight", initialSelectedModel = null, + initialReasoningEffort = null, initialChatProject = null, emptyStateSuggestions, layout = "sidebar", @@ -113,6 +145,7 @@ export function ChatSidebar({ onConsumeQueuedMessage?: () => void queuedMessageSource?: "highlight" | "home" initialSelectedModel?: ModelId | null + initialReasoningEffort?: ReasoningEffort | null initialChatProject?: string | null emptyStateSuggestions?: string[] layout?: "sidebar" | "page" @@ -123,8 +156,17 @@ export function ChatSidebar({ const [selectedModel, setSelectedModel] = useState( initialSelectedModel ?? "claude-sonnet-4.6", ) + const [reasoningEffort, setReasoningEffort] = useState( + initialReasoningEffort ?? + getDefaultReasoningEffort(initialSelectedModel ?? "claude-sonnet-4.6"), + ) const selectedModelRef = useRef(selectedModel) 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< @@ -146,6 +188,22 @@ export function ChatSidebar({ const isScrolledToBottomRef = useRef(true) const userJustSentRef = useRef(false) const sentQueuedMessageRef = useRef(null) + const truncateFromMessageIdRef = useRef(null) + const pendingRegenerationRef = useRef<{ + text: string + } | null>(null) + const pendingSendSettingsRef = useRef<{ + model: ModelId + reasoningEffort: ReasoningEffort + } | null>(null) + const pendingResponseModelsRef = useRef([]) + const seenAssistantMessageIdsRef = useRef>(new Set()) + const [responseModelByMessageId, setResponseModelByMessageId] = useState< + Record + >({}) + const [regenerationBaseLength, setRegenerationBaseLength] = useState< + number | null + >(null) const pendingHighlightReplyRef = useRef(null) const awaitingHighlightInjectionRef = useRef(false) const pendingHighlightMessageRef = useRef(null) @@ -224,22 +282,30 @@ export function ChatSidebar({ new DefaultChatTransport({ api: `${chatApiBase}/chat`, credentials: "include", - prepareSendMessagesRequest: ({ messages }) => ({ - body: { - messages, - metadata: { - chatId: chatIdRef.current, - projectId: selectedProjectRef.current, - spaceMode: - selectedProjectRef.current === AUTO_CHAT_SPACE_ID - ? "auto" - : "manual", - enableSpaceDiscovery: - selectedProjectRef.current === AUTO_CHAT_SPACE_ID, - model: selectedModelRef.current, + prepareSendMessagesRequest: ({ messages }) => { + const sendSettings = pendingSendSettingsRef.current + pendingSendSettingsRef.current = null + + return { + body: { + messages, + metadata: { + chatId: chatIdRef.current, + projectId: selectedProjectRef.current, + spaceMode: + selectedProjectRef.current === AUTO_CHAT_SPACE_ID + ? "auto" + : "manual", + enableSpaceDiscovery: + selectedProjectRef.current === AUTO_CHAT_SPACE_ID, + model: sendSettings?.model ?? selectedModelRef.current, + reasoningEffort: + sendSettings?.reasoningEffort ?? reasoningEffortRef.current, + truncateFromMessageId: truncateFromMessageIdRef.current, + }, }, - }, - }), + } + }, }), [chatApiBase], ) @@ -291,9 +357,38 @@ export function ChatSidebar({ [error, selectedModel], ) + useEffect(() => { + if (error) { + pendingResponseModelsRef.current = [] + } + }, [error]) + + useEffect(() => { + const updates: Record = {} + + for (const message of messages) { + if (message.role !== "assistant") continue + if (seenAssistantMessageIdsRef.current.has(message.id)) continue + + seenAssistantMessageIdsRef.current.add(message.id) + const responseModel = + getMessageResponseModel(message) ?? + pendingResponseModelsRef.current.shift() ?? + null + + if (responseModel) { + updates[message.id] = responseModel + } + } + + if (Object.keys(updates).length === 0) return + setResponseModelByMessageId((prev) => ({ ...prev, ...updates })) + }, [messages]) + const handleModelChange = useCallback( (modelId: ModelId) => { setSelectedModel(modelId) + setReasoningEffort(getDefaultReasoningEffort(modelId)) clearError() }, [clearError], @@ -333,11 +428,36 @@ 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 }) + pendingResponseModelsRef.current.push(selectedModel) + sendMessage({ text }) setInput("") userJustSentRef.current = true scrollToBottom() @@ -346,9 +466,11 @@ export function ChatSidebar({ const handleSuggestedQuestion = useCallback( (suggestion: string) => { if (status === "submitted" || status === "streaming") return + truncateFromMessageIdRef.current = null if (!threadId) setThreadId(fallbackChatId) analytics.chatSuggestedQuestionClicked() analytics.chatMessageSent({ source: "suggested" }) + pendingResponseModelsRef.current.push(selectedModel) sendMessage({ text: suggestion }) userJustSentRef.current = true scrollToBottom() @@ -360,9 +482,66 @@ export function ChatSidebar({ status, threadId, scrollToBottom, + selectedModel, ], ) + const handleRegenerateFromUserMessage = useCallback( + ( + messageId: string, + text: string, + model: ModelId, + nextReasoningEffort: ReasoningEffort, + ) => { + const trimmed = text.trim() + if (!trimmed || status === "submitted" || status === "streaming") return + const messageIndex = messages.findIndex( + (message) => message.id === messageId, + ) + if (messageIndex === -1) return + + truncateFromMessageIdRef.current = messageId + pendingSendSettingsRef.current = { + model, + reasoningEffort: nextReasoningEffort, + } + clearError() + pendingRegenerationRef.current = { + text: trimmed, + } + setRegenerationBaseLength(messageIndex) + setMessages(messages.slice(0, messageIndex)) + userJustSentRef.current = true + scrollToBottom() + }, + [clearError, messages, scrollToBottom, setMessages, status], + ) + + useEffect(() => { + const pending = pendingRegenerationRef.current + if ( + !pending || + regenerationBaseLength === null || + messages.length !== regenerationBaseLength || + status !== "ready" + ) { + return + } + + pendingRegenerationRef.current = null + setRegenerationBaseLength(null) + analytics.chatMessageSent({ source: "typed" }) + queuedDispatchInFlightRef.current = true + queuedDispatchSawResponseRef.current = false + pendingResponseModelsRef.current.push( + pendingSendSettingsRef.current?.model ?? selectedModelRef.current, + ) + sendMessage({ text: pending.text }) + window.setTimeout(() => { + truncateFromMessageIdRef.current = null + }, 0) + }, [messages.length, regenerationBaseLength, sendMessage, status]) + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault() @@ -425,6 +604,12 @@ export function ChatSidebar({ setThreadId(null) setFallbackChatId(newChatId) setInput("") + setMessageQueue([]) + pendingResponseModelsRef.current = [] + seenAssistantMessageIdsRef.current = new Set() + setResponseModelByMessageId({}) + queuedDispatchInFlightRef.current = false + queuedDispatchSawResponseRef.current = false }, [setThreadId, setMessages]) const fetchThreads = useCallback(async () => { @@ -466,6 +651,7 @@ export function ChatSidebar({ role: string parts: Array<{ type: string }> createdAt: string + metadata?: Record }) => ({ id: m.id, role: m.role, @@ -475,11 +661,18 @@ export function ChatSidebar({ parts: (m.parts || []).filter( (p) => p.type === "text" || p.type === "reasoning", ), + metadata: m.metadata, createdAt: new Date(m.createdAt), }), ) + pendingResponseModelsRef.current = [] + seenAssistantMessageIdsRef.current = new Set() + setResponseModelByMessageId({}) setThreadId(id) setPendingThreadLoad({ id, messages: uiMessages }) + setMessageQueue([]) + queuedDispatchInFlightRef.current = false + queuedDispatchSawResponseRef.current = false analytics.chatThreadLoaded({ thread_id: id }) setIsHistoryOpen(false) setConfirmingDeleteId(null) @@ -568,10 +761,18 @@ export function ChatSidebar({ setSelectedModel(initialSelectedModel) return } + if ( + initialReasoningEffort && + reasoningEffort !== initialReasoningEffort + ) { + setReasoningEffort(initialReasoningEffort) + return + } sentQueuedMessageRef.current = queuedMessage analytics.chatMessageSent({ source: queuedMessageSource }) if (queuedHighlightContent) { + truncateFromMessageIdRef.current = null // Start a fresh thread for highlight-based chats to avoid overwriting existing conversations const newChatId = generateId() chatIdRef.current = newChatId @@ -602,7 +803,11 @@ export function ChatSidebar({ }, ] } else { + truncateFromMessageIdRef.current = null if (!threadId) setThreadId(fallbackChatId) + queuedDispatchInFlightRef.current = true + queuedDispatchSawResponseRef.current = false + pendingResponseModelsRef.current.push(selectedModel) sendMessage({ text: queuedMessage }) } onConsumeQueuedMessage?.() @@ -613,7 +818,9 @@ export function ChatSidebar({ queuedHighlightContent, queuedMessageSource, initialSelectedModel, + initialReasoningEffort, selectedModel, + reasoningEffort, status, sendMessage, onConsumeQueuedMessage, @@ -653,6 +860,10 @@ export function ChatSidebar({ awaitingHighlightInjectionRef.current = false const reply = pendingHighlightReplyRef.current pendingHighlightReplyRef.current = null + truncateFromMessageIdRef.current = null + queuedDispatchInFlightRef.current = true + queuedDispatchSawResponseRef.current = false + pendingResponseModelsRef.current.push(selectedModelRef.current) sendMessage({ text: reply }) } }, [messages, sendMessage, status]) @@ -664,6 +875,57 @@ 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, + } + pendingResponseModelsRef.current.push(nextMessage.model) + 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 @@ -780,7 +1042,9 @@ export function ChatSidebar({ const isStackedInput = layout === "page" const showHeaderRow = !isPageDesktop || isMobile || !isStackedInput const isResponding = status === "submitted" || status === "streaming" - const showInputStatusStrip = !isStackedInput + const showInputStatusStrip = + !isStackedInput || isResponding || messages.length > 0 + const isQueueFull = messageQueue.length >= CHAT_QUEUE_LIMIT const chatHistorySheet = ( + ) : ( + void disabled: boolean + disabledTooltip?: string }) { const button = ( @@ -43,7 +45,7 @@ export function SendButton({ {button} - Type a message to send + {disabledTooltip} ) } diff --git a/apps/web/components/chat/input/index.tsx b/apps/web/components/chat/input/index.tsx index d24276d09..36ceb9b97 100644 --- a/apps/web/components/chat/input/index.tsx +++ b/apps/web/components/chat/input/index.tsx @@ -1,12 +1,20 @@ "use client" -import { ChevronUpIcon } from "lucide-react" +import { BrainIcon, ChevronUpIcon, ZapIcon } from "lucide-react" import NovaOrb from "@/components/nova/nova-orb" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { type ReactNode, useEffect, useRef, useState } from "react" -import { motion } from "motion/react" +import { AnimatePresence, motion } from "motion/react" import { SendButton, StopButton } from "./actions" +import { type ModelId, modelNames, type ReasoningEffort } from "@/lib/models" + +export interface QueuedChatMessagePreview { + id: string + text: string + model: ModelId + reasoningEffort: ReasoningEffort +} interface ChatInputProps { value: string @@ -15,7 +23,10 @@ interface ChatInputProps { onStop: () => void onKeyDown?: (e: React.KeyboardEvent) => void isResponding?: boolean + sendDisabled?: boolean + sendDisabledTooltip?: string activeStatus?: string + queuedMessages?: QueuedChatMessagePreview[] chainOfThoughtComponent?: React.ReactNode onExpandedChange?: (expanded: boolean) => void /** Model + space controls on one row with send; textarea full-width above */ @@ -31,7 +42,10 @@ export default function ChatInput({ onStop, onKeyDown, isResponding = false, + sendDisabled = false, + sendDisabledTooltip, activeStatus, + queuedMessages = [], chainOfThoughtComponent, onExpandedChange, stackedToolbar, @@ -40,6 +54,12 @@ export default function ChatInput({ const [isMultiline, setIsMultiline] = useState(false) const [isExpanded, setIsExpanded] = useState(false) const textareaRef = useRef(null) + const isSendDisabled = !value.trim() || sendDisabled + const hasQueuedPreview = queuedMessages.length > 0 + const resolvedSendDisabledTooltip = + sendDisabled && value.trim() + ? sendDisabledTooltip + : "Type a message to send" useEffect(() => { if (!showStatusStrip && isExpanded) { @@ -101,7 +121,8 @@ export default function ChatInput({ + {hasQueuedPreview && ( +
+ + {queuedMessages.map((queued) => { + const model = modelNames[queued.model] + const ReasoningIcon = + queued.reasoningEffort === "thinking" ? BrainIcon : ZapIcon + return ( + +
+ + {queued.text} + + + + {model.name} {model.version} + + · + + +
+
+ ) + })} +
+
+ )} ) : null} {stackedToolbar ? ( -
+