add edit feature , reasoning mode, message queue in nova chat (#1018)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Ishaan Gupta <ishaankone@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Vedant Mahajan 2026-06-03 00:28:58 +05:30 committed by GitHub
parent 0e29de0af2
commit 73c320c83b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 837 additions and 99 deletions

View file

@ -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<string | null>(null)
const [queuedChatModel, setQueuedChatModel] = useState<ModelId | null>(null)
const [queuedChatReasoningEffort, setQueuedChatReasoningEffort] =
useState<ReasoningEffort | null>(null)
const [queuedChatProject, setQueuedChatProject] = useState<string | null>(
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}
/>
</div>

View file

@ -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<ModelId>("gemini-2.5-pro")
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
getDefaultReasoningEffort("gemini-2.5-pro"),
)
const { selectedProject } = useProject()
const [chatSpaceProjects, setChatSpaceProjects] = useState<string[]>([
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({
<>
<ChatModelSelector
selectedModel={selectedModel}
onModelChange={setSelectedModel}
onModelChange={handleModelChange}
minimal
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}

View file

@ -36,7 +36,12 @@ import { getNovaChatErrorCopy } from "@/lib/chat-stream-error"
import { useProject } from "@/stores"
import { useContainerTags } from "@/hooks/use-container-tags"
import { getChatSpaceDisplayLabel } from "@/lib/chat-space-label"
import { modelNames, type ModelId } from "@/lib/models"
import {
getDefaultReasoningEffort,
modelNames,
type ModelId,
type ReasoningEffort,
} from "@/lib/models"
import { SpaceSelector } from "@/components/space-selector"
import { SuperLoader } from "../superloader"
import { UserMessage } from "./message/user-message"
@ -51,6 +56,7 @@ import { useViewMode } from "@/lib/view-mode-context"
import { threadParam } from "@/lib/search-params"
import { AUTO_CHAT_SPACE_ID } from "@/lib/chat-auto-space"
import { ChatEmptyStatePlaceholder } from "./chat-empty-state"
import { ReasoningSelector } from "./reasoning-selector"
export function ChatLaunchFab({
onOpen,
@ -94,6 +100,31 @@ export function ChatLaunchFab({
)
}
type QueuedChatMessage = {
id: string
text: string
model: ModelId
reasoningEffort: ReasoningEffort
}
const CHAT_QUEUE_LIMIT = 3
function normalizeModelId(value: unknown): ModelId | null {
if (typeof value !== "string") return null
return value in modelNames ? (value as ModelId) : null
}
function getMessageResponseModel(message: UIMessage): ModelId | null {
const metadata = (
message as UIMessage & { metadata?: Record<string, unknown> }
).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<ModelId>(
initialSelectedModel ?? "claude-sonnet-4.6",
)
const [reasoningEffort, setReasoningEffort] = useState<ReasoningEffort>(
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<QueuedChatMessage[]>([])
const queuedDispatchInFlightRef = useRef(false)
const queuedDispatchSawResponseRef = useRef(false)
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null)
const [hoveredMessageId, setHoveredMessageId] = useState<string | null>(null)
const [messageFeedback, setMessageFeedback] = useState<
@ -146,6 +188,22 @@ export function ChatSidebar({
const isScrolledToBottomRef = useRef(true)
const userJustSentRef = useRef(false)
const sentQueuedMessageRef = useRef<string | null>(null)
const truncateFromMessageIdRef = useRef<string | null>(null)
const pendingRegenerationRef = useRef<{
text: string
} | null>(null)
const pendingSendSettingsRef = useRef<{
model: ModelId
reasoningEffort: ReasoningEffort
} | null>(null)
const pendingResponseModelsRef = useRef<ModelId[]>([])
const seenAssistantMessageIdsRef = useRef<Set<string>>(new Set())
const [responseModelByMessageId, setResponseModelByMessageId] = useState<
Record<string, ModelId>
>({})
const [regenerationBaseLength, setRegenerationBaseLength] = useState<
number | null
>(null)
const pendingHighlightReplyRef = useRef<string | null>(null)
const awaitingHighlightInjectionRef = useRef(false)
const pendingHighlightMessageRef = useRef<UIMessage[] | null>(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<string, ModelId> = {}
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 && !isMobile) {
e.preventDefault()
@ -436,6 +615,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 () => {
@ -477,6 +662,7 @@ export function ChatSidebar({
role: string
parts: Array<{ type: string }>
createdAt: string
metadata?: Record<string, unknown>
}) => ({
id: m.id,
role: m.role,
@ -486,11 +672,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)
@ -579,10 +772,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
@ -613,7 +814,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?.()
@ -624,7 +829,9 @@ export function ChatSidebar({
queuedHighlightContent,
queuedMessageSource,
initialSelectedModel,
initialReasoningEffort,
selectedModel,
reasoningEffort,
status,
sendMessage,
onConsumeQueuedMessage,
@ -664,6 +871,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])
@ -675,6 +886,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
@ -791,7 +1053,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 = (
<Sheet
@ -972,6 +1236,10 @@ export function ChatSidebar({
selectedModel={selectedModel}
onModelChange={handleModelChange}
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}
@ -1017,6 +1285,7 @@ export function ChatSidebar({
? cn(
"flex flex-col space-y-3 min-h-full justify-end",
isPageDesktop || isMobile ? "pt-2" : "pt-14",
isStackedInput && "px-4",
)
: ""
}
@ -1041,7 +1310,10 @@ export function ChatSidebar({
<UserMessage
message={message}
copiedMessageId={copiedMessageId}
selectedModel={selectedModel}
reasoningEffort={reasoningEffort}
onCopy={handleCopyMessage}
onRegenerate={handleRegenerateFromUserMessage}
/>
) : (
<AgentMessage
@ -1052,6 +1324,7 @@ export function ChatSidebar({
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
expandedMemories={expandedMemories}
responseModel={responseModelByMessageId[message.id] ?? null}
onCopy={handleCopyMessage}
onLike={handleLikeMessage}
onDislike={handleDislikeMessage}
@ -1151,13 +1424,18 @@ export function ChatSidebar({
onStop={handleStop}
onKeyDown={handleKeyDown}
isResponding={isResponding}
sendDisabled={isResponding && isQueueFull}
sendDisabledTooltip={`Queue is full (${CHAT_QUEUE_LIMIT} max)`}
activeStatus={
status === "submitted"
? "Thinking…"
: status === "streaming"
? "Structuring response…"
: "Waiting for input…"
isResponding && isQueueFull
? `Queue full (${CHAT_QUEUE_LIMIT} max)`
: status === "submitted"
? "Thinking…"
: status === "streaming"
? "Structuring response…"
: "Waiting for input…"
}
queuedMessages={messageQueue}
showStatusStrip={showInputStatusStrip}
onExpandedChange={setIsInputExpanded}
chainOfThoughtComponent={
@ -1171,6 +1449,10 @@ export function ChatSidebar({
onModelChange={handleModelChange}
minimal
/>
<ReasoningSelector
value={reasoningEffort}
onChange={setReasoningEffort}
/>
<SpaceSelector
selectedProjects={chatSpaceProjects}
onValueChange={setChatSpaceProjects}

View file

@ -5,9 +5,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/components/tooltip"
export function SendButton({
onClick,
disabled,
disabledTooltip = "Type a message to send",
}: {
onClick: () => void
disabled: boolean
disabledTooltip?: string
}) {
const button = (
<button
@ -31,7 +33,7 @@ export function SendButton({
<title>Send Icon</title>
<path
d="M12 6L10.55 7.4L7 3.85L7 16L5 16L5 3.85L1.45 7.4L-4.37e-07 6L6 -2.62e-07L12 6Z"
fill="#FAFAFA"
fill="#9CA3AF"
/>
</svg>
</button>
@ -43,7 +45,7 @@ export function SendButton({
<TooltipTrigger asChild>
<span className="inline-flex">{button}</span>
</TooltipTrigger>
<TooltipContent side="top">Type a message to send</TooltipContent>
<TooltipContent side="top">{disabledTooltip}</TooltipContent>
</Tooltip>
)
}

View file

@ -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<HTMLTextAreaElement>(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({
<button
type="button"
className={cn(
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left",
"w-full p-3 pr-4 flex items-center justify-between cursor-pointer bg-transparent border-0 text-left transition-[padding] duration-200",
hasQueuedPreview && "pb-1.5",
!chainOfThoughtComponent && "disabled:cursor-not-allowed",
)}
onClick={() => {
@ -126,10 +147,55 @@ export default function ChatInput({
/>
)}
</button>
{hasQueuedPreview && (
<div className="flex flex-col gap-1 px-3 pr-4 pb-3">
<AnimatePresence initial={false}>
{queuedMessages.map((queued) => {
const model = modelNames[queued.model]
const ReasoningIcon =
queued.reasoningEffort === "thinking" ? BrainIcon : ZapIcon
return (
<motion.div
key={queued.id}
layout
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="flex min-w-0 items-center gap-2 px-2.5 py-1">
<span
className={cn(
"min-w-0 flex-1 truncate text-xs text-white/35",
dmSansClassName(),
)}
>
{queued.text}
</span>
<span
className={cn(
"flex shrink-0 items-center gap-1.5 text-[10px] text-white/28",
dmSansClassName(),
)}
>
<span className="truncate">
{model.name} {model.version}
</span>
<span className="text-white/18">·</span>
<ReasoningIcon className="size-3 shrink-0 text-white/30" />
</span>
</div>
</motion.div>
)
})}
</AnimatePresence>
</div>
)}
</>
) : null}
{stackedToolbar ? (
<div className="flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
<div className="relative z-30 flex flex-col gap-2 rounded-xl bg-surface-card/60 backdrop-blur-md p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] transition-all duration-200 focus-within:ring-1 focus-within:ring-fg-primary/10">
<textarea
ref={textareaRef}
value={value}
@ -139,17 +205,19 @@ export default function ChatInput({
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
{stackedToolbar}
</div>
<div className="shrink-0">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
<div className="flex shrink-0 items-center gap-1.5">
{isResponding && <StopButton onClick={onStop} />}
{(!isResponding || value.trim()) && (
<SendButton
onClick={onSend}
disabled={isSendDisabled}
disabledTooltip={resolvedSendDisabledTooltip}
/>
)}
</div>
</div>
@ -170,13 +238,15 @@ export default function ChatInput({
className="w-full resize-none overflow-y-auto bg-transparent p-2 text-fg-primary transition-all duration-200 placeholder:text-fg-faint focus:outline-none"
style={{ minHeight: "36px" }}
rows={1}
disabled={isResponding}
/>
<div className="transition-all duration-200">
{isResponding ? (
<StopButton onClick={onStop} />
) : (
<SendButton onClick={onSend} disabled={!value.trim()} />
<div className="flex items-center gap-1.5 transition-all duration-200">
{isResponding && <StopButton onClick={onStop} />}
{(!isResponding || value.trim()) && (
<SendButton
onClick={onSend}
disabled={isSendDisabled}
disabledTooltip={resolvedSendDisabledTooltip}
/>
)}
</div>
</div>

View file

@ -19,6 +19,7 @@ import {
} from "lucide-react"
import { cn } from "@lib/utils"
import { isWebSearchToolName } from "@/lib/chat-web-search-tools"
import { modelNames, type ModelId } from "@/lib/models"
import { RelatedMemories } from "./related-memories"
import { MessageActions } from "./message-actions"
@ -313,6 +314,7 @@ interface AgentMessageProps {
copiedMessageId: string | null
messageFeedback: Record<string, "like" | "dislike" | null>
expandedMemories: string | null
responseModel: ModelId | null
onCopy: (messageId: string, text: string) => void
onLike: (messageId: string) => void
onDislike: (messageId: string) => void
@ -327,6 +329,7 @@ export function AgentMessage({
copiedMessageId,
messageFeedback,
expandedMemories,
responseModel,
onCopy,
onLike,
onDislike,
@ -339,6 +342,9 @@ export function AgentMessage({
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const responseModelLabel = responseModel
? `${modelNames[responseModel].name} ${modelNames[responseModel].version}`
: null
return (
<div className="flex flex-col gap-1 w-full">
@ -442,17 +448,29 @@ export function AgentMessage({
})}
</div>
</div>
<MessageActions
messageId={message.id}
messageText={messageText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
<div className="flex min-h-7 items-center gap-2">
<MessageActions
messageId={message.id}
messageText={messageText}
isLastMessage={isLastAgentMessage}
isHovered={isHovered}
copiedMessageId={copiedMessageId}
messageFeedback={messageFeedback}
onCopy={onCopy}
onLike={onLike}
onDislike={onDislike}
/>
{responseModelLabel && (
<span
className={cn(
"text-[10px] leading-none text-white/25 transition-opacity duration-200",
isHovered ? "opacity-100" : "opacity-0",
)}
>
{responseModelLabel}
</span>
)}
</div>
</div>
)
}

View file

@ -1,4 +1,4 @@
import { Copy, Check, ThumbsUp, ThumbsDown } from "lucide-react"
import { Check, Copy, ThumbsDown, ThumbsUp } from "lucide-react"
import { cn } from "@lib/utils"
interface MessageActionsProps {

View file

@ -1,42 +1,165 @@
"use client"
import { memo } from "react"
import { Copy, Check } from "lucide-react"
import { memo, useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import { Copy, Check, PencilIcon, PencilOffIcon } from "lucide-react"
import type { UIMessage } from "@ai-sdk/react"
import ChatModelSelector from "../model-selector"
import { ReasoningSelector } from "../reasoning-selector"
import { SendButton } from "../input/actions"
import {
getDefaultReasoningEffort,
type ModelId,
type ReasoningEffort,
} from "@/lib/models"
interface UserMessageProps {
message: UIMessage
copiedMessageId: string | null
selectedModel: ModelId
reasoningEffort: ReasoningEffort
onCopy: (messageId: string, text: string) => void
onRegenerate: (
messageId: string,
text: string,
model: ModelId,
reasoningEffort: ReasoningEffort,
) => void
}
export const UserMessage = memo(function UserMessage({
message,
copiedMessageId,
selectedModel,
reasoningEffort,
onCopy,
onRegenerate,
}: UserMessageProps) {
const [isEditing, setIsEditing] = useState(false)
const [draft, setDraft] = useState("")
const [editModel, setEditModel] = useState<ModelId>(selectedModel)
const [editReasoningEffort, setEditReasoningEffort] =
useState<ReasoningEffort>(reasoningEffort)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const text = message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join(" ")
const startEditing = () => {
setDraft(text)
setEditModel(selectedModel)
setEditReasoningEffort(reasoningEffort)
setIsEditing(true)
}
const submitEdit = () => {
const nextText = draft.trim()
if (!nextText) return
setIsEditing(false)
onRegenerate(message.id, nextText, editModel, editReasoningEffort)
}
const handleEditModelChange = (model: ModelId) => {
setEditModel(model)
setEditReasoningEffort(getDefaultReasoningEffort(model))
}
useEffect(() => {
if (!isEditing) return
textareaRef.current?.focus()
}, [isEditing])
return (
<div className="flex flex-col items-end w-full">
<div className="bg-[#1B1F24] rounded-[12px] p-3 px-[14px] max-w-[80%]">
<p className="text-sm text-white">{text}</p>
</div>
<button
type="button"
onClick={() => onCopy(message.id, text)}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors mt-1"
title="Copy message"
>
{copiedMessageId === message.id ? (
<Check className="size-3.5 text-green-400" />
<AnimatePresence mode="popLayout" initial={false}>
{isEditing ? (
<motion.div
key="edit"
initial={{ opacity: 0, scaleX: 0.4, scaleY: 0.6 }}
animate={{ opacity: 1, scaleX: 1, scaleY: 1 }}
exit={{ opacity: 0, scaleX: 0.4, scaleY: 0.6 }}
transition={{ duration: 0.55, ease: [0.22, 0.68, 0.18, 1] }}
className="relative z-20 w-full max-w-[88%] origin-right rounded-xl bg-surface-card/60 p-2 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-md"
>
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
submitEdit()
}
}}
className="min-h-20 w-full resize-none bg-transparent p-2 text-sm text-white outline-none placeholder:text-white/30"
/>
<div className="mt-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<ChatModelSelector
selectedModel={editModel}
onModelChange={handleEditModelChange}
minimal
dropdownDirection="up"
/>
<ReasoningSelector
value={editReasoningEffort}
onChange={setEditReasoningEffort}
dropdownDirection="up"
/>
</div>
<div className="flex shrink-0 items-center gap-1">
<SendButton
onClick={submitEdit}
disabled={!draft.trim()}
disabledTooltip="Type a message to send"
/>
</div>
</div>
</motion.div>
) : (
<Copy className="size-3.5 text-white/50" />
<motion.div
key="view"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="max-w-[80%] origin-top-right rounded-[12px] bg-[#1B1F24] p-3 px-[14px]"
>
<p className="text-sm text-white">{text}</p>
</motion.div>
)}
</button>
</AnimatePresence>
<motion.div
layout
transition={{ layout: { duration: 0.28, ease: [0.22, 1, 0.36, 1] } }}
className="mt-1 flex max-w-full items-center justify-end gap-1"
>
<button
type="button"
onClick={() => onCopy(message.id, text)}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors"
title="Copy message"
>
{copiedMessageId === message.id ? (
<Check className="size-3.5 text-green-400" />
) : (
<Copy className="size-3.5 text-white/50" />
)}
</button>
<button
type="button"
onClick={isEditing ? () => setIsEditing(false) : startEditing}
className="p-1.5 hover:bg-[#293952]/40 rounded transition-colors"
title={isEditing ? "Cancel edit" : "Edit message"}
>
{isEditing ? (
<PencilOffIcon className="size-3.5 text-white/50" />
) : (
<PencilIcon className="size-3.5 text-white/50" />
)}
</button>
</motion.div>
</div>
)
})

View file

@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { Button } from "@ui/components/button"
import { dmSansClassName } from "@/lib/fonts"
import { ChevronDownIcon } from "lucide-react"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
import { models, type ModelId, modelNames } from "@/lib/models"
import { analytics } from "@/lib/analytics"
@ -13,12 +13,14 @@ interface ChatModelSelectorProps {
onModelChange?: (model: ModelId) => void
/** Compact pill matching inline send control. */
minimal?: boolean
dropdownDirection?: "up" | "down"
}
export default function ChatModelSelector({
selectedModel: selectedModelProp,
onModelChange,
minimal = false,
dropdownDirection = "up",
}: ChatModelSelectorProps = {}) {
const [internalModel, setInternalModel] =
useState<ModelId>("claude-sonnet-4.6")
@ -41,6 +43,9 @@ export default function ChatModelSelector({
const selectedModel = selectedModelProp ?? internalModel
const currentModelData = modelNames[selectedModel]
const selectedModelLabel = `${currentModelData.name} ${currentModelData.version}`
const selectedItemClass =
"border border-[#267BF1]/35 bg-[#0A1A3A] text-white shadow-[inset_0_0_0_1px_rgba(75,160,250,0.08)]"
const handleModelSelect = (modelId: ModelId) => {
if (onModelChange) {
@ -56,71 +61,102 @@ export default function ChatModelSelector({
<button
type="button"
className={cn(
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full bg-fg-primary/5 px-3 py-1.5 text-sm transition-colors hover:bg-fg-primary/10",
"flex max-w-[min(100%,220px)] min-w-0 shrink cursor-pointer items-center gap-1.5 rounded-full border border-white/15 bg-black px-3 py-1.5 text-[13px] text-white transition-colors hover:border-white/30 hover:bg-white/5",
dmSansClassName(),
)}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-label={`Model: ${selectedModelLabel}`}
>
<p className="min-w-0 truncate text-left text-fg-primary">
<p className="min-w-0 truncate text-left text-white">
{currentModelData.name}{" "}
<span className="text-fg-subtle">{currentModelData.version}</span>
<span className="text-white/55">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-3.5 shrink-0 text-fg-subtle" />
<ChevronDownIcon className="size-3.5 shrink-0 text-white/55" />
</button>
) : (
<Button
variant="headers"
className={cn(
"h-10! max-w-[min(100%,220px)] shrink gap-1 rounded-full border-[#73737333] bg-surface-base text-base",
"h-10! max-w-[min(100%,220px)] shrink gap-1.5 rounded-full border-white/15 bg-black text-base text-white shadow-none transition-colors hover:border-white/30 hover:bg-white/5",
dmSansClassName(),
)}
style={{
boxShadow: "1.5px 1.5px 4.5px 0 rgba(0, 0, 0, 0.70) inset",
}}
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-label={`Model: ${selectedModelLabel}`}
>
<p className="truncate text-sm">
{currentModelData.name}{" "}
<span className="text-[#737373]">{currentModelData.version}</span>
<span className="text-white/55">{currentModelData.version}</span>
</p>
<ChevronDownIcon className="size-4 text-[#737373]" />
<ChevronDownIcon className="size-4 text-white/55" />
</Button>
)
return (
<div
ref={containerRef}
className="relative z-10 flex min-w-0 shrink items-center gap-2"
className={cn(
"relative flex min-w-0 shrink items-center gap-2",
isOpen ? "z-[1000]" : "z-10",
)}
>
{trigger}
{isOpen && (
<div className="absolute bottom-full left-0 mb-2 w-64 bg-surface-card backdrop-blur-xl border border-surface-border rounded-lg shadow-xl z-50 overflow-hidden">
<div className="p-2 space-y-1">
<div
className={cn(
"isolate absolute left-0 z-[1000] w-[min(18rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="space-y-1">
{models.map((model) => {
const modelData = modelNames[model.id]
const isSelected = selectedModel === model.id
return (
<button
key={model.id}
type="button"
className={cn(
"flex flex-col items-start p-2 px-3 rounded-md transition-colors cursor-pointer w-full text-left",
selectedModel === model.id
? "bg-[#293952]/60"
: "hover:bg-[#293952]/40",
"flex w-full cursor-pointer items-center gap-3 rounded-lg border border-transparent px-3 py-2.5 text-left transition-colors",
isSelected
? selectedItemClass
: "text-white hover:bg-white/10",
)}
onClick={() => handleModelSelect(model.id)}
onKeyDown={(e) =>
e.key === "Enter" && handleModelSelect(model.id)
}
>
<div className="text-sm font-medium text-white">
{modelData.name}{" "}
<span className="text-fg-subtle">{modelData.version}</span>
</div>
<div className="text-xs text-fg-muted truncate w-full">
{model.description}
<div className="min-w-0 flex-1">
<div
className={cn(
"truncate text-sm font-medium",
isSelected ? "text-white" : "text-white",
)}
>
{modelData.name}{" "}
<span
className={cn(
isSelected ? "text-[#8DBDFF]" : "text-white/55",
)}
>
{modelData.version}
</span>
</div>
<div
className={cn(
"mt-0.5 truncate text-xs",
isSelected ? "text-white/60" : "text-white/45",
)}
>
{model.description}
</div>
</div>
{isSelected && (
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
)}
</button>
)
})}

View file

@ -0,0 +1,143 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
BrainIcon,
CheckIcon,
ChevronDownIcon,
MoreHorizontalIcon,
ZapIcon,
} from "lucide-react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
import { reasoningOptions, type ReasoningEffort } from "@/lib/models"
interface ReasoningSelectorProps {
value: ReasoningEffort
onChange: (value: ReasoningEffort) => void
variant?: "pill" | "icon"
disabled?: boolean
dropdownDirection?: "up" | "down"
}
export function ReasoningSelector({
value,
onChange,
variant = "pill",
disabled = false,
dropdownDirection = "up",
}: ReasoningSelectorProps) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
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
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [isOpen])
const handleSelect = (next: ReasoningEffort) => {
onChange(next)
setIsOpen(false)
}
return (
<div
ref={containerRef}
className={cn(
"relative flex shrink-0 items-center",
isOpen ? "z-[1000]" : "z-10",
)}
>
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen((open) => !open)}
className={cn(
"cursor-pointer transition-colors disabled:cursor-not-allowed disabled:opacity-50",
variant === "icon"
? "rounded p-1.5 hover:bg-white/10"
: "flex size-9 items-center justify-center gap-1.5 rounded-full border border-white/15 bg-black px-0 py-1.5 text-xs text-white hover:border-white/30 hover:bg-white/5 sm:size-auto sm:justify-start sm:px-2.5",
dmSansClassName(),
)}
title={`Reasoning: ${selectedLabel}`}
aria-label={`Reasoning: ${selectedLabel}`}
aria-expanded={isOpen}
>
{variant === "icon" ? (
<MoreHorizontalIcon className="size-3.5 text-white/50 hover:text-white/80" />
) : (
<>
<SelectedIcon className="size-3.5 shrink-0 text-white/65" />
<span className="hidden text-white sm:inline">
{selected?.label}
</span>
<ChevronDownIcon className="hidden size-3.5 shrink-0 text-white/55 sm:block" />
</>
)}
</button>
{isOpen && (
<div
className={cn(
"isolate absolute left-0 z-[1000] w-[min(14rem,calc(100vw-2rem))] overflow-hidden rounded-xl border border-white/15 bg-black p-1 shadow-[0_18px_48px_rgba(0,0,0,0.55)]",
dropdownDirection === "up" ? "bottom-full mb-2" : "top-full mt-2",
)}
>
<div className="space-y-1">
{reasoningOptions.map((option) => {
const Icon = option.id === "thinking" ? BrainIcon : ZapIcon
const isSelected = option.id === value
return (
<button
key={option.id}
type="button"
onClick={() => handleSelect(option.id)}
className={cn(
"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
? selectedItemClass
: "text-white hover:bg-white/10",
)}
>
<Icon
className={cn(
"size-4 shrink-0",
isSelected ? "text-[#8DBDFF]" : "text-white/65",
)}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-medium",
isSelected ? "text-white" : "text-white",
)}
>
{option.label}
</div>
</div>
{isSelected && (
<CheckIcon className="size-4 shrink-0 text-[#8DBDFF]" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View file

@ -17,6 +17,7 @@ export const models = [
] as const
export type ModelId = (typeof models)[number]["id"]
export type ReasoningEffort = "instant" | "thinking"
export const modelNames: Record<ModelId, { name: string; version: string }> = {
"gpt-5.1": { name: "GPT", version: "5.1" },
@ -24,6 +25,27 @@ export const modelNames: Record<ModelId, { name: string; version: string }> = {
"gemini-2.5-pro": { name: "Gemini", version: "3 Pro" },
}
export const reasoningOptions: Array<{
id: ReasoningEffort
label: string
description: string
}> = [
{
id: "instant",
label: "Instant",
description: "Faster answers for everyday prompts",
},
{
id: "thinking",
label: "Thinking",
description: "Deeper reasoning for harder questions",
},
]
export function getDefaultReasoningEffort(_model: ModelId): ReasoningEffort {
return "instant"
}
interface ModelIconProps {
width?: number
height?: number